diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/common/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/common/index.ts index cfb4987862535..81b8a6ac56292 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/common/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/common/index.ts @@ -51,3 +51,5 @@ export { DEFAULT_LANGUAGE_OPTION, LANGUAGE_OPTIONS } from './ui_settings/languag export { isSupportedConnectorType } from './connectors'; export { ShortIdTable } from './utils/short_id_table'; + +export { KnowledgeBaseType } from './types'; 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 bd1a284b0d363..68595e457a355 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 @@ -84,6 +84,7 @@ export interface KnowledgeBaseEntry { doc_id: string; confidence: 'low' | 'medium' | 'high'; is_correction: boolean; + type?: 'user_instruction' | 'contextual'; public: boolean; labels?: Record; role: KnowledgeBaseEntryRole; @@ -92,13 +93,26 @@ export interface KnowledgeBaseEntry { }; } -export interface UserInstruction { +export interface Instruction { doc_id: string; text: string; - system?: boolean; } -export type UserInstructionOrPlainText = string | UserInstruction; +export interface AdHocInstruction { + doc_id?: string; + text: string; + instruction_type: 'user_instruction' | 'application_instruction'; +} + +export type InstructionOrPlainText = string | Instruction; + +export enum KnowledgeBaseType { + // user instructions are included in the system prompt regardless of the user's input + UserInstruction = 'user_instruction', + + // contextual entries are only included in the system prompt if the user's input matches the context + Contextual = 'contextual', +} export interface ObservabilityAIAssistantScreenContextRequest { screenDescription?: string; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts index 2e604b59fc7ab..7e7ed20de18e3 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/public/index.ts @@ -78,6 +78,8 @@ export type { ShortIdTable, } from '../common'; +export { KnowledgeBaseType } from '../common'; + export type { TelemetryEventTypeWithPayload } from './analytics'; export { ObservabilityAIAssistantTelemetryEventType } from './analytics/telemetry_event_type'; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/public/types.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/public/types.ts index 8480af2e02327..c00cf4805ff8d 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/public/types.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/public/types.ts @@ -18,7 +18,7 @@ import type { Message, ObservabilityAIAssistantScreenContext, PendingMessage, - UserInstructionOrPlainText, + AdHocInstruction, } from '../common/types'; import type { TelemetryEventTypeWithPayload } from './analytics'; import type { ObservabilityAIAssistantAPIClient } from './api'; @@ -68,7 +68,7 @@ export interface ObservabilityAIAssistantChatService { }; signal: AbortSignal; responseLanguage?: string; - instructions?: UserInstructionOrPlainText[]; + instructions?: AdHocInstruction[]; }) => Observable; getFunctions: (options?: { contexts?: string[]; filter?: string }) => FunctionDefinition[]; hasFunction: (name: string) => boolean; 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 ff14f5a3d3db3..8865861d81f45 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,6 +5,7 @@ * 2.0. */ +import { KnowledgeBaseType } from '../../common/types'; import type { FunctionRegistrationParameters } from '.'; import { KnowledgeBaseEntryRole } from '../../common'; @@ -66,13 +67,14 @@ export function registerSummarizationFunction({ signal ) => { return client - .createKnowledgeBaseEntry({ + .addKnowledgeBaseEntry({ entry: { doc_id: id, role: KnowledgeBaseEntryRole.AssistantSummarization, id, text, is_correction: isCorrection, + type: KnowledgeBaseType.Contextual, confidence, public: isPublic, labels: {}, 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 f1758c1583f71..73d8b0d1354e0 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 @@ -41,17 +41,15 @@ const chatCompleteBaseRt = t.type({ }), ]), instructions: t.array( - t.union([ - t.string, - t.intersection([ - t.type({ - doc_id: t.string, - text: t.string, - }), - t.partial({ - system: t.boolean, - }), - ]), + t.intersection([ + t.partial({ doc_id: t.string }), + t.type({ + text: t.string, + instruction_type: t.union([ + t.literal('user_instruction'), + t.literal('application_instruction'), + ]), + }), ]) ), }), 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 52be33c2a372d..fae7077953699 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 @@ -41,7 +41,7 @@ const getFunctionsRoute = createObservabilityAIAssistantServerRoute({ screenContexts: [], }), // error is caught in client - client.fetchUserInstructions(), + client.getKnowledgeBaseUserInstructions(), ]); const functionDefinitions = functionClient.getFunctions().map((fn) => fn.definition); @@ -51,9 +51,9 @@ const getFunctionsRoute = createObservabilityAIAssistantServerRoute({ return { functionDefinitions: functionClient.getFunctions().map((fn) => fn.definition), systemMessage: getSystemMessageFromInstructions({ - registeredInstructions: functionClient.getInstructions(), + applicationInstructions: functionClient.getInstructions(), userInstructions, - requestInstructions: [], + adHocInstructions: [], availableFunctionNames, }), }; @@ -111,6 +111,7 @@ const functionSummariseRoute = createObservabilityAIAssistantServerRoute({ 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), }), @@ -129,17 +130,19 @@ const functionSummariseRoute = createObservabilityAIAssistantServerRoute({ confidence, id, is_correction: isCorrection, + type, text, public: isPublic, labels, } = resources.params.body; - return client.createKnowledgeBaseEntry({ + return client.addKnowledgeBaseEntry({ entry: { confidence, id, doc_id: id, 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 6dfbde7654039..6bb024b913cde 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 @@ -13,7 +13,12 @@ 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 { KnowledgeBaseEntry, KnowledgeBaseEntryRole } from '../../../common/types'; +import { + Instruction, + KnowledgeBaseEntry, + KnowledgeBaseEntryRole, + KnowledgeBaseType, +} from '../../../common/types'; const getKnowledgeBaseStatus = createObservabilityAIAssistantServerRoute({ endpoint: 'GET /internal/observability_ai_assistant/kb/status', @@ -60,6 +65,64 @@ const setupKnowledgeBase = createObservabilityAIAssistantServerRoute({ }, }); +const getKnowledgeBaseUserInstructions = createObservabilityAIAssistantServerRoute({ + endpoint: 'GET /internal/observability_ai_assistant/kb/user_instructions', + options: { + tags: ['access:ai_assistant'], + }, + handler: async ( + resources + ): Promise<{ + userInstructions: Array; + }> => { + const client = await resources.service.getClient({ request: resources.request }); + + if (!client) { + throw notImplemented(); + } + + return { + userInstructions: await client.getKnowledgeBaseUserInstructions(), + }; + }, +}); + +const saveKnowledgeBaseUserInstruction = createObservabilityAIAssistantServerRoute({ + endpoint: 'PUT /internal/observability_ai_assistant/kb/user_instructions', + params: t.type({ + body: t.type({ + id: t.string, + text: nonEmptyStringRt, + public: toBooleanRt, + }), + }), + options: { + tags: ['access:ai_assistant'], + }, + handler: async (resources): Promise => { + const client = await resources.service.getClient({ request: resources.request }); + + if (!client) { + throw notImplemented(); + } + + 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, + }, + }); + }, +}); + const getKnowledgeBaseEntries = createObservabilityAIAssistantServerRoute({ endpoint: 'GET /internal/observability_ai_assistant/kb/entries', options: { @@ -130,13 +193,14 @@ const saveKnowledgeBaseEntry = createObservabilityAIAssistantServerRoute({ role, } = resources.params.body; - return client.createKnowledgeBaseEntry({ + 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, @@ -192,6 +256,7 @@ const importKnowledgeBaseEntries = createObservabilityAIAssistantServerRoute({ doc_id: entry.id, confidence: 'high' as KnowledgeBaseEntry['confidence'], is_correction: false, + type: 'contextual' as const, public: true, labels: {}, role: KnowledgeBaseEntryRole.UserEntry, @@ -206,6 +271,8 @@ export const knowledgeBaseRoutes = { ...setupKnowledgeBase, ...getKnowledgeBaseStatus, ...getKnowledgeBaseEntries, + ...saveKnowledgeBaseUserInstruction, + ...getKnowledgeBaseUserInstructions, ...importKnowledgeBaseEntries, ...saveKnowledgeBaseEntry, ...deleteKnowledgeBaseEntry, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/chat_function_client/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/chat_function_client/index.ts index 0b0f202e703cd..fc90a4e0caa3d 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/chat_function_client/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/chat_function_client/index.ts @@ -16,7 +16,7 @@ import type { FunctionCallChatFunction, FunctionHandler, FunctionHandlerRegistry, - RegisteredInstruction, + InstructionOrCallback, RegisterFunction, RegisterInstruction, } from '../types'; @@ -34,7 +34,7 @@ const ajv = new Ajv({ export const GET_DATA_ON_SCREEN_FUNCTION_NAME = 'get_data_on_screen'; export class ChatFunctionClient { - private readonly instructions: RegisteredInstruction[] = []; + private readonly instructions: InstructionOrCallback[] = []; private readonly functionRegistry: FunctionHandlerRegistry = new Map(); private readonly validators: Map = new Map(); @@ -107,7 +107,7 @@ export class ChatFunctionClient { } } - getInstructions(): RegisteredInstruction[] { + getInstructions(): InstructionOrCallback[] { return this.instructions; } diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/index.test.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/index.test.ts index cbe033ed0e4b9..33bd0ab49b1d7 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/index.test.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/index.test.ts @@ -26,7 +26,6 @@ import { createFunctionResponseMessage } from '../../../common/utils/create_func import { CONTEXT_FUNCTION_NAME } from '../../functions/context'; import { ChatFunctionClient } from '../chat_function_client'; import type { KnowledgeBaseService } from '../knowledge_base_service'; -import { USER_INSTRUCTIONS_HEADER } from '../util/get_system_message_from_instructions'; import { observableIntoStream } from '../util/observable_into_stream'; import { CreateChatCompletionResponseChunk } from './adapters/process_openai_stream'; @@ -34,7 +33,7 @@ type ChunkDelta = CreateChatCompletionResponseChunk['choices'][number]['delta']; type LlmSimulator = ReturnType; -const EXPECTED_STORED_SYSTEM_MESSAGE = `system\n\n${USER_INSTRUCTIONS_HEADER}\n\nYou MUST respond in the users preferred language which is: English.`; +const EXPECTED_STORED_SYSTEM_MESSAGE = `system\n\nYou MUST respond in the users preferred language which is: English.`; const nextTick = () => { return new Promise(process.nextTick); @@ -367,8 +366,8 @@ describe('Observability AI Assistant client', () => { last_updated: expect.any(String), token_count: { completion: 1, - prompt: 84, - total: 85, + prompt: 46, + total: 47, }, }, type: StreamingChatResponseEventType.ConversationCreate, @@ -424,8 +423,8 @@ describe('Observability AI Assistant client', () => { last_updated: expect.any(String), token_count: { completion: 6, - prompt: 268, - total: 274, + prompt: 230, + total: 236, }, }, type: StreamingChatResponseEventType.ConversationCreate, @@ -442,8 +441,8 @@ describe('Observability AI Assistant client', () => { title: 'An auto-generated title', token_count: { completion: 6, - prompt: 268, - total: 274, + prompt: 230, + total: 236, }, }, labels: {}, @@ -573,8 +572,8 @@ describe('Observability AI Assistant client', () => { last_updated: expect.any(String), token_count: { completion: 2, - prompt: 162, - total: 164, + prompt: 124, + total: 126, }, }, type: StreamingChatResponseEventType.ConversationUpdate, @@ -592,8 +591,8 @@ describe('Observability AI Assistant client', () => { title: 'My stored conversation', token_count: { completion: 2, - prompt: 162, - total: 164, + prompt: 124, + total: 126, }, }, labels: {}, @@ -1609,7 +1608,10 @@ describe('Observability AI Assistant client', () => { .subscribe(() => {}); // To trigger call to chat await nextTick(); - expect(chatSpy.mock.calls[0][1].messages[0].message.content).toEqual( + const systemMessage = chatSpy.mock.calls[0][1].messages[0]; + + expect(systemMessage.message.role).toEqual(MessageRole.System); + expect(systemMessage.message.content).toEqual( EXPECTED_STORED_SYSTEM_MESSAGE.replace('English', 'Orcish') ); }); 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 4c3527873d01c..293d2da9c04b9 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 @@ -46,12 +46,12 @@ import { } from '../../../common/conversation_complete'; import { CompatibleJSONSchema } from '../../../common/functions/types'; import { - UserInstructionOrPlainText, type Conversation, type ConversationCreateRequest, type ConversationUpdateRequest, type KnowledgeBaseEntry, type Message, + type AdHocInstruction, } from '../../../common/types'; import { withoutTokenCountEvents } from '../../../common/utils/without_token_count_events'; import { CONTEXT_FUNCTION_NAME } from '../../functions/context'; @@ -159,7 +159,21 @@ export class ObservabilityAIAssistantClient { }); }; - complete = (params: { + complete = ({ + functionClient, + connectorId, + simulateFunctionCalling, + instructions: adHocInstructions = [], + messages: initialMessages, + signal, + responseLanguage = 'English', + persist, + kibanaPublicUrl, + isPublic, + title: predefinedTitle, + conversationId: predefinedConversationId, + disableFunctions = false, + }: { messages: Message[]; connectorId: string; signal: AbortSignal; @@ -170,7 +184,7 @@ export class ObservabilityAIAssistantClient { title?: string; isPublic?: boolean; kibanaPublicUrl?: string; - instructions?: UserInstructionOrPlainText[]; + instructions?: AdHocInstruction[]; simulateFunctionCalling?: boolean; disableFunctions?: | boolean @@ -181,26 +195,11 @@ export class ObservabilityAIAssistantClient { return new LangTracer(context.active()).startActiveSpan( 'complete', ({ tracer: completeTracer }) => { - const { - functionClient, - connectorId, - simulateFunctionCalling, - instructions: requestInstructions = [], - messages: initialMessages, - signal, - responseLanguage = 'English', - persist, - kibanaPublicUrl, - isPublic, - title: predefinedTitle, - conversationId: predefinedConversationId, - disableFunctions = false, - } = params; - if (responseLanguage) { - requestInstructions.push( - `You MUST respond in the users preferred language which is: ${responseLanguage}.` - ); + adHocInstructions.push({ + instruction_type: 'application_instruction', + text: `You MUST respond in the users preferred language which is: ${responseLanguage}.`, + }); } const isConversationUpdate = persist && !!predefinedConversationId; @@ -208,14 +207,15 @@ export class ObservabilityAIAssistantClient { const conversationId = persist ? predefinedConversationId || v4() : ''; if (persist && !isConversationUpdate && kibanaPublicUrl) { - requestInstructions.push( - `This conversation will be persisted in Kibana and available at this url: ${ + adHocInstructions.push({ + instruction_type: 'application_instruction', + text: `This conversation will be persisted in Kibana and available at this url: ${ kibanaPublicUrl + `/app/observabilityAIAssistant/conversations/${conversationId}` - }.` - ); + }.`, + }); } - const userInstructions$ = from(this.fetchUserInstructions()).pipe(shareReplay()); + const userInstructions$ = from(this.getKnowledgeBaseUserInstructions()).pipe(shareReplay()); // from the initial messages, override any system message with // the one that is based on the instructions (registered, request, kb) @@ -224,9 +224,9 @@ export class ObservabilityAIAssistantClient { // this is what we eventually store in the conversation const messagesWithUpdatedSystemMessage = replaceSystemMessage( getSystemMessageFromInstructions({ - registeredInstructions: functionClient.getInstructions(), + applicationInstructions: functionClient.getInstructions(), userInstructions, - requestInstructions, + adHocInstructions, availableFunctionNames: functionClient .getFunctions() .map((fn) => fn.definition.name), @@ -303,7 +303,7 @@ export class ObservabilityAIAssistantClient { functionCallsLeft: MAX_FUNCTION_CALLS, functionClient, userInstructions, - requestInstructions, + adHocInstructions, signal, logger: this.dependencies.logger, disableFunctions, @@ -731,7 +731,7 @@ export class ObservabilityAIAssistantClient { return this.dependencies.knowledgeBaseService.setup(); }; - createKnowledgeBaseEntry = async ({ + addKnowledgeBaseEntry = async ({ entry, }: { entry: Omit; @@ -772,7 +772,7 @@ export class ObservabilityAIAssistantClient { return this.dependencies.knowledgeBaseService.deleteEntry({ id }); }; - fetchUserInstructions = async () => { + getKnowledgeBaseUserInstructions = async () => { return this.dependencies.knowledgeBaseService.getUserInstructions( this.dependencies.namespace, this.dependencies.user diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/continue_conversation.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/continue_conversation.ts index 237eea9411b23..a06fca2e13278 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/continue_conversation.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/continue_conversation.ts @@ -28,7 +28,7 @@ import { MessageOrChatEvent, } from '../../../../common/conversation_complete'; import { FunctionVisibility } from '../../../../common/functions/types'; -import { UserInstruction } from '../../../../common/types'; +import { AdHocInstruction, Instruction } from '../../../../common/types'; import { createFunctionResponseMessage } from '../../../../common/utils/create_function_response_message'; import { emitWithConcatenatedMessage } from '../../../../common/utils/emit_with_concatenated_message'; import { withoutTokenCountEvents } from '../../../../common/utils/without_token_count_events'; @@ -171,7 +171,7 @@ export function continueConversation({ chat, signal, functionCallsLeft, - requestInstructions, + adHocInstructions, userInstructions, logger, disableFunctions, @@ -182,8 +182,8 @@ export function continueConversation({ chat: ChatFunctionWithoutConnector; signal: AbortSignal; functionCallsLeft: number; - requestInstructions: Array; - userInstructions: UserInstruction[]; + adHocInstructions: AdHocInstruction[]; + userInstructions: Instruction[]; logger: Logger; disableFunctions: | boolean @@ -204,9 +204,9 @@ export function continueConversation({ const messagesWithUpdatedSystemMessage = replaceSystemMessage( getSystemMessageFromInstructions({ - registeredInstructions: functionClient.getInstructions(), + applicationInstructions: functionClient.getInstructions(), userInstructions, - requestInstructions, + adHocInstructions, availableFunctionNames: definitions.map((def) => def.name), }), initialMessages @@ -325,7 +325,7 @@ export function continueConversation({ functionClient, signal, userInstructions, - requestInstructions, + adHocInstructions, logger, disableFunctions, tracer, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/get_generated_title.test.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/get_generated_title.test.ts index ef538ef27bdfd..02f5e5c294466 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/get_generated_title.test.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/get_generated_title.test.ts @@ -13,7 +13,7 @@ import { } from '../../../../common'; import { ChatEvent } from '../../../../common/conversation_complete'; import { LangTracer } from '../instrumentation/lang_tracer'; -import { getGeneratedTitle } from './get_generated_title'; +import { TITLE_CONVERSATION_FUNCTION_NAME, getGeneratedTitle } from './get_generated_title'; describe('getGeneratedTitle', () => { const messages: Message[] = [ @@ -83,7 +83,7 @@ describe('getGeneratedTitle', () => { const { chatSpy, title$ } = callGenerateTitle([ createChatCompletionChunk({ function_call: { - name: 'title_conversation', + name: TITLE_CONVERSATION_FUNCTION_NAME, arguments: JSON.stringify({ title: 'My title' }), }, }), diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/get_generated_title.ts b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/get_generated_title.ts index 2b635c6d0ccf9..23440c30bdcca 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/get_generated_title.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant/server/service/client/operators/get_generated_title.ts @@ -14,6 +14,8 @@ import { hideTokenCountEvents } from './hide_token_count_events'; import { ChatEvent, TokenCountEvent } from '../../../../common/conversation_complete'; import { LangTracer } from '../instrumentation/lang_tracer'; +export const TITLE_CONVERSATION_FUNCTION_NAME = 'title_conversation'; + type ChatFunctionWithoutConnectorAndTokenCount = ( name: string, params: Omit< @@ -59,7 +61,7 @@ export function getGeneratedTitle({ ], functions: [ { - name: 'title_conversation', + name: TITLE_CONVERSATION_FUNCTION_NAME, description: 'Use this function to title the conversation. Do not wrap the title in quotes', parameters: { @@ -73,7 +75,7 @@ export function getGeneratedTitle({ }, }, ], - functionCall: 'title_conversation', + functionCall: TITLE_CONVERSATION_FUNCTION_NAME, tracer, }).pipe( hide(), 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 eddcb8112f540..c087e5940f0b7 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 @@ -323,7 +323,7 @@ export class ObservabilityAIAssistantService { return fnClient; } - addToKnowledgeBase(entries: KnowledgeBaseEntryRequest[]): void { + addToKnowledgeBaseQueue(entries: KnowledgeBaseEntryRequest[]): void { this.init() .then(() => { this.kbService!.queue( @@ -334,6 +334,7 @@ export class ObservabilityAIAssistantService { doc_id: entry.id, public: true, confidence: 'high' as const, + type: 'contextual' as const, is_correction: false, labels: { ...entry.labels, @@ -364,7 +365,7 @@ export class ObservabilityAIAssistantService { } addCategoryToKnowledgeBase(categoryId: string, entries: KnowledgeBaseEntryRequest[]) { - this.addToKnowledgeBase( + this.addToKnowledgeBaseQueue( entries.map((entry) => { return { ...entry, 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 c28821c3d8517..a4c6dc25d2e57 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 @@ -38,6 +38,7 @@ export const kbComponentTemplate: ClusterComponentTemplate['component_template'] name: keyword, }, }, + type: keyword, labels: dynamic, conversation: { properties: { 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 6c62cce3ebdb7..679d59a57dbc8 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 @@ -19,7 +19,12 @@ import { INDEX_QUEUED_DOCUMENTS_TASK_TYPE, resourceNames, } from '..'; -import { KnowledgeBaseEntry, KnowledgeBaseEntryRole, UserInstruction } from '../../../common/types'; +import { + Instruction, + KnowledgeBaseEntry, + KnowledgeBaseEntryRole, + KnowledgeBaseType, +} from '../../../common/types'; import { getAccessQuery } from '../util/get_access_query'; import { getCategoryQuery } from '../util/get_category_query'; import { recallFromConnectors } from './recall_from_connectors'; @@ -355,6 +360,9 @@ export class KnowledgeBaseService { namespace, }), ...getCategoryQuery({ categories }), + + // exclude user instructions + { bool: { must_not: { term: { type: KnowledgeBaseType.UserInstruction } } } }, ], }, }; @@ -425,6 +433,13 @@ export class KnowledgeBaseService { }), ]); + this.dependencies.logger.debug( + `documentsFromKb: ${JSON.stringify(documentsFromKb.slice(0, 5), null, 2)}` + ); + this.dependencies.logger.debug( + `documentsFromConnectors: ${JSON.stringify(documentsFromConnectors.slice(0, 5), null, 2)}` + ); + const sortedEntries = orderBy( documentsFromKb.concat(documentsFromConnectors), 'score', @@ -458,34 +473,30 @@ export class KnowledgeBaseService { getUserInstructions = async ( namespace: string, user?: { name: string } - ): Promise => { + ): Promise> => { try { const response = await this.dependencies.esClient.asInternalUser.search({ index: resourceNames.aliases.kb, query: { bool: { - must: [ + filter: [ { term: { - 'labels.category.keyword': { - value: 'instruction', - }, + type: KnowledgeBaseType.UserInstruction, }, }, + ...getAccessQuery({ user, namespace }), ], - filter: getAccessQuery({ - user, - namespace, - }), }, }, size: 500, - _source: ['doc_id', 'text'], + _source: ['doc_id', 'text', 'public'], }); return response.hits.hits.map((hit) => ({ doc_id: hit._source?.doc_id ?? '', text: hit._source?.text ?? '', + public: hit._source?.public, })); } catch (error) { this.dependencies.logger.error('Failed to load instructions from knowledge base'); @@ -506,17 +517,18 @@ export class KnowledgeBaseService { try { const response = await this.dependencies.esClient.asInternalUser.search({ index: resourceNames.aliases.kb, - ...(query - ? { - query: { - wildcard: { - doc_id: { - value: `${query}*`, - }, - }, + query: { + bool: { + filter: [ + // filter title by query + ...(query ? [{ wildcard: { doc_id: { value: `${query}*` } } }] : []), + { + // exclude user instructions + bool: { must_not: { term: { type: KnowledgeBaseType.UserInstruction } } }, }, - } - : {}), + ], + }, + }, sort: [ { [String(sortBy)]: { @@ -536,6 +548,7 @@ export class KnowledgeBaseService { '@timestamp', 'role', 'user.name', + 'type', ], }, }); @@ -556,6 +569,35 @@ export class KnowledgeBaseService { } }; + getExistingUserInstructionId = async ({ + isPublic, + user, + namespace, + }: { + isPublic: boolean; + user?: { name: string; id?: string }; + namespace?: string; + }) => { + const res = await this.dependencies.esClient.asInternalUser.search< + Pick + >({ + index: resourceNames.aliases.kb, + query: { + bool: { + filter: [ + { term: { type: KnowledgeBaseType.UserInstruction } }, + { term: { public: isPublic } }, + ...getAccessQuery({ user, namespace }), + ], + }, + }, + size: 1, + _source: ['doc_id'], + }); + + return res.hits.hits[0]?._source?.doc_id; + }; + addEntry = async ({ entry: { id, ...document }, user, @@ -565,6 +607,20 @@ export class KnowledgeBaseService { user?: { name: string; id?: string }; namespace?: string; }): Promise => { + // 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({ index: resourceNames.aliases.kb, 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 6e6256c3b8b90..cd8e25843ca59 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 @@ -16,7 +16,7 @@ import type { import type { Message, ObservabilityAIAssistantScreenContextRequest, - UserInstructionOrPlainText, + InstructionOrPlainText, } from '../../common/types'; import type { ObservabilityAIAssistantRouteHandlerResources } from '../routes/types'; import { ChatFunctionClient } from './chat_function_client'; @@ -63,17 +63,15 @@ export interface FunctionHandler { respond: RespondFunction; } -export type RegisteredInstruction = UserInstructionOrPlainText | RegisterInstructionCallback; +export type InstructionOrCallback = InstructionOrPlainText | RegisterInstructionCallback; type RegisterInstructionCallback = ({ availableFunctionNames, }: { availableFunctionNames: string[]; -}) => UserInstructionOrPlainText | UserInstructionOrPlainText[] | undefined; +}) => InstructionOrPlainText | InstructionOrPlainText[] | undefined; -export type RegisterInstruction = ( - ...instructions: Array -) => void; +export type RegisterInstruction = (...instructions: InstructionOrCallback[]) => void; export type RegisterFunction = < TParameters extends CompatibleJSONSchema = any, 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 93594fc520998..8e4075bed7b9d 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 @@ -13,9 +13,9 @@ describe('getSystemMessageFromInstructions', () => { it('handles plain instructions', () => { expect( getSystemMessageFromInstructions({ - registeredInstructions: ['first', 'second'], + applicationInstructions: ['first', 'second'], userInstructions: [], - requestInstructions: [], + adHocInstructions: [], availableFunctionNames: [], }) ).toEqual(`first\n\nsecond`); @@ -24,36 +24,42 @@ describe('getSystemMessageFromInstructions', () => { it('handles callbacks', () => { expect( getSystemMessageFromInstructions({ - registeredInstructions: [ + applicationInstructions: [ 'first', ({ availableFunctionNames }) => { return availableFunctionNames[0]; }, ], userInstructions: [], - requestInstructions: [], + adHocInstructions: [], availableFunctionNames: ['myFunction'], }) ).toEqual(`first\n\nmyFunction`); }); - it('overrides kb instructions with request instructions', () => { + it('overrides kb instructions with adhoc instructions', () => { expect( getSystemMessageFromInstructions({ - registeredInstructions: ['first'], - userInstructions: [{ doc_id: 'second', text: 'second_kb' }], - requestInstructions: [{ doc_id: 'second', text: 'second_request' }], + applicationInstructions: ['first'], + userInstructions: [{ doc_id: 'second', text: 'second from kb' }], + adHocInstructions: [ + { + doc_id: 'second', + text: 'second from adhoc instruction', + instruction_type: 'user_instruction', + }, + ], availableFunctionNames: [], }) - ).toEqual(`first\n\n${USER_INSTRUCTIONS_HEADER}\n\nsecond_request`); + ).toEqual(`first\n\n${USER_INSTRUCTIONS_HEADER}\n\nsecond from adhoc instruction`); }); it('includes kb instructions if there is no request instruction', () => { expect( getSystemMessageFromInstructions({ - registeredInstructions: ['first'], + applicationInstructions: ['first'], userInstructions: [{ doc_id: 'second', text: 'second_kb' }], - requestInstructions: [], + adHocInstructions: [], availableFunctionNames: [], }) ).toEqual(`first\n\n${USER_INSTRUCTIONS_HEADER}\n\nsecond_kb`); @@ -62,14 +68,14 @@ describe('getSystemMessageFromInstructions', () => { it('handles undefined values', () => { expect( getSystemMessageFromInstructions({ - registeredInstructions: [ + applicationInstructions: [ 'first', ({ availableFunctionNames }) => { return undefined; }, ], userInstructions: [], - requestInstructions: [], + adHocInstructions: [], availableFunctionNames: [], }) ).toEqual(`first`); 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 759ff07125b95..b2797577883ba 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 @@ -5,11 +5,11 @@ * 2.0. */ -import { compact, partition } from 'lodash'; +import { compact, partition, uniqBy } from 'lodash'; import { v4 } from 'uuid'; -import { UserInstruction, UserInstructionOrPlainText } from '../../../common/types'; +import { AdHocInstruction, Instruction } from '../../../common/types'; import { withTokenBudget } from '../../../common/utils/with_token_budget'; -import { RegisteredInstruction } from '../types'; +import { InstructionOrCallback } from '../types'; export const USER_INSTRUCTIONS_HEADER = `## User instructions @@ -19,18 +19,23 @@ as long as they don't conflict with anything you've been told so far: `; export function getSystemMessageFromInstructions({ - registeredInstructions, - userInstructions, - requestInstructions, + // application instructions registered by the functions. These will be displayed first + applicationInstructions, + + // instructions provided by the user. These will be displayed after the application instructions and only if they fit within the token budget + userInstructions: kbUserInstructions, + + // ad-hoc instruction. Can be either user or application instruction + adHocInstructions, availableFunctionNames, }: { - registeredInstructions: RegisteredInstruction[]; - userInstructions: UserInstruction[]; - requestInstructions: UserInstructionOrPlainText[]; + applicationInstructions: InstructionOrCallback[]; + userInstructions: Instruction[]; + adHocInstructions: AdHocInstruction[]; availableFunctionNames: string[]; }): string { - const allRegisteredInstructions = compact( - registeredInstructions.flatMap((instruction) => { + const allApplicationInstructions = compact( + applicationInstructions.flatMap((instruction) => { if (typeof instruction === 'function') { return instruction({ availableFunctionNames }); } @@ -38,31 +43,30 @@ export function getSystemMessageFromInstructions({ }) ); - const requestInstructionsWithId = requestInstructions.map((instruction) => - typeof instruction === 'string' - ? { doc_id: v4(), text: instruction, system: false } - : instruction - ); + const adHocInstructionsWithId = adHocInstructions.map((adHocInstruction) => ({ + ...adHocInstruction, + doc_id: adHocInstruction.doc_id ?? v4(), + })); - const [requestSystemInstructions, requestUserInstructionsWithId] = partition( - requestInstructionsWithId, - (instruction) => instruction.system === true + // split ad hoc instructions into user instructions and application instructions + const [adHocUserInstructions, adHocApplicationInstructions] = partition( + adHocInstructionsWithId, + (instruction) => instruction.instruction_type === 'user_instruction' ); - const requestOverrideIds = requestUserInstructionsWithId.map((instruction) => instruction.doc_id); - - // all request instructions, and those from the KB that are not defined as a request instruction - const allUserInstructions = requestInstructionsWithId.concat( - userInstructions.filter((instruction) => !requestOverrideIds.includes(instruction.doc_id)) + // all adhoc instructions and KB instructions. + // adhoc instructions will be prioritized over Knowledge Base instructions if the doc_id is the same + const allUserInstructions = withTokenBudget( + uniqBy([...adHocUserInstructions, ...kbUserInstructions], (i) => i.doc_id), + 1000 ); - const instructionsWithinBudget = withTokenBudget(allUserInstructions, 1000); - return [ - ...allRegisteredInstructions.concat(requestSystemInstructions), - ...(instructionsWithinBudget.length - ? [USER_INSTRUCTIONS_HEADER, ...instructionsWithinBudget] - : []), + // application instructions + ...allApplicationInstructions.concat(adHocApplicationInstructions), + + // user instructions + ...(allUserInstructions.length ? [USER_INSTRUCTIONS_HEADER, ...allUserInstructions] : []), ] .map((instruction) => { return typeof instruction === 'string' ? instruction : instruction.text; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc b/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc index 7408f5e5dd1c9..6e2ba07fb699c 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/kibana.jsonc @@ -20,11 +20,10 @@ "uiActions", "triggersActionsUi", "share", - "security", "licensing", "ml", "alerting", - "features", + "features" ], "requiredBundles": ["kibanaReact", "esqlDataGrid"], "optionalPlugins": ["cloud"], diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_current_user.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_current_user.ts index 1ca539eb88370..82c13eb876117 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_current_user.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/hooks/use_current_user.ts @@ -11,11 +11,7 @@ import { useKibana } from './use_kibana'; export function useCurrentUser() { const { - services: { - plugins: { - start: { security }, - }, - }, + services: { security }, } = useKibana(); const [user, setUser] = useState(); diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/types.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/types.ts index d6235da273839..398ef5f3afe4c 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/types.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/public/types.ts @@ -16,7 +16,6 @@ import type { ObservabilityAIAssistantPublicStart, } from '@kbn/observability-ai-assistant-plugin/public'; import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public'; -import type { SecurityPluginStart, SecurityPluginSetup } from '@kbn/security-plugin/public'; import type { LicensingPluginSetup, LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { ObservabilitySharedPluginSetup, @@ -41,7 +40,6 @@ export interface ObservabilityAIAssistantAppPublicSetup {} export interface ObservabilityAIAssistantAppPluginStartDependencies { licensing: LicensingPluginStart; share: SharePluginStart; - security: SecurityPluginStart; lens: LensPublicStart; dataViews: DataViewsPublicPluginStart; uiActions: UiActionsStart; @@ -56,7 +54,6 @@ export interface ObservabilityAIAssistantAppPluginStartDependencies { export interface ObservabilityAIAssistantAppPluginSetupDependencies { licensing: LicensingPluginSetup; share: SharePluginSetup; - security: SecurityPluginSetup; lens: LensPublicSetup; dataViews: DataViewsPublicPluginSetup; uiActions: UiActionsSetup; diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.ts index aac5637e7d5bb..34b9dd36ea77f 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_app/server/rule_connector/index.ts @@ -36,6 +36,7 @@ import { concatenateChatCompletionChunks } from '@kbn/observability-ai-assistant import { CompatibleJSONSchema } from '@kbn/observability-ai-assistant-plugin/common/functions/types'; import { AlertDetailsContextualInsightsService } from '@kbn/observability-plugin/server/services'; import { getSystemMessageFromInstructions } from '@kbn/observability-ai-assistant-plugin/server/service/util/get_system_message_from_instructions'; +import { AdHocInstruction } from '@kbn/observability-ai-assistant-plugin/common/types'; import { convertSchemaToOpenApi } from './convert_schema_to_open_api'; import { OBSERVABILITY_AI_ASSISTANT_CONNECTOR_ID } from '../../common/rule_connector'; @@ -177,12 +178,15 @@ async function executor( }); }); - const backgroundInstruction = dedent( - `You are called as a background process because alerts have changed state. - As a background process you are not interacting with a user. Because of that DO NOT ask for user - input if tasked to execute actions. You can generate multiple responses in a row. - If available, include the link of the conversation at the end of your answer.` - ); + const backgroundInstruction: AdHocInstruction = { + instruction_type: 'application_instruction', + text: dedent( + `You are called as a background process because alerts have changed state. +As a background process you are not interacting with a user. Because of that DO NOT ask for user +input if tasked to execute actions. You can generate multiple responses in a row. +If available, include the link of the conversation at the end of your answer.` + ), + }; const alertsContext = await getAlertsContext( execOptions.params.rule, @@ -223,9 +227,9 @@ async function executor( role: MessageRole.System, content: getSystemMessageFromInstructions({ availableFunctionNames: functionClient.getFunctions().map((fn) => fn.definition.name), - registeredInstructions: functionClient.getInstructions(), + applicationInstructions: functionClient.getInstructions(), userInstructions: [], - requestInstructions: [], + adHocInstructions: [], }), }, }, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/constants.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/constants.ts index d48005c41cd62..a680da5ed3f93 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/constants.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/constants.ts @@ -8,6 +8,7 @@ export const REACT_QUERY_KEYS = { GET_GENAI_CONNECTORS: 'get_genai_connectors', GET_KB_ENTRIES: 'get_kb_entries', + GET_KB_USER_INSTRUCTIONS: 'get_kb_user_instructions', CREATE_KB_ENTRIES: 'create_kb_entry', IMPORT_KB_ENTRIES: 'import_kb_entry', }; 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 57bf36c507a7a..459de7be2d528 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 @@ -22,7 +22,7 @@ export function useCreateKnowledgeBaseEntry() { } = useKibana().services; const queryClient = useQueryClient(); - const observabilityAIAssistantApi = observabilityAIAssistant?.service.callApi; + const observabilityAIAssistantApi = observabilityAIAssistant.service.callApi; return useMutation< void, @@ -36,11 +36,7 @@ export function useCreateKnowledgeBaseEntry() { >( [REACT_QUERY_KEYS.CREATE_KB_ENTRIES], ({ entry }) => { - if (!observabilityAIAssistantApi) { - return Promise.reject('Error with observabilityAIAssistantApi: API not found.'); - } - - return observabilityAIAssistantApi?.( + return observabilityAIAssistantApi( 'POST /internal/observability_ai_assistant/kb/entries/save', { signal: null, @@ -59,8 +55,7 @@ export function useCreateKnowledgeBaseEntry() { i18n.translate( 'xpack.observabilityAiAssistantManagement.kb.addManualEntry.successNotification', { - defaultMessage: 'Successfully created {name}', - values: { name: entry.id }, + defaultMessage: 'Entry saved', } ) ); 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 new file mode 100644 index 0000000000000..b51e45a3bdd6b --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_create_knowledge_base_user_instruction.ts @@ -0,0 +1,72 @@ +/* + * 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 { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { type Instruction } from '@kbn/observability-ai-assistant-plugin/common/types'; +import { REACT_QUERY_KEYS } from '../constants'; +import { useKibana } from './use_kibana'; + +type ServerError = IHttpFetchError; + +export function useCreateKnowledgeBaseUserInstruction() { + const { + observabilityAIAssistant, + notifications: { toasts }, + } = useKibana().services; + + const queryClient = useQueryClient(); + const observabilityAIAssistantApi = observabilityAIAssistant.service.callApi; + + return useMutation( + [REACT_QUERY_KEYS.CREATE_KB_ENTRIES], + ({ entry }) => { + return observabilityAIAssistantApi( + 'PUT /internal/observability_ai_assistant/kb/user_instructions', + { + signal: null, + params: { + body: { + id: entry.doc_id, + text: entry.text, + public: entry.public, + }, + }, + } + ); + }, + { + onSuccess: (_data, { entry }) => { + toasts.addSuccess( + i18n.translate( + 'xpack.observabilityAiAssistantManagement.kb.addUserInstruction.successNotification', + { + defaultMessage: 'User instruction saved', + } + ) + ); + + queryClient.invalidateQueries({ + queryKey: [REACT_QUERY_KEYS.GET_KB_USER_INSTRUCTIONS], + refetchType: 'all', + }); + }, + onError: (error, { entry }) => { + toasts.addError(new Error(error.body?.message ?? error.message), { + title: i18n.translate( + 'xpack.observabilityAiAssistantManagement.kb.addUserInstruction.errorNotification', + { + defaultMessage: 'Something went wrong while creating {name}', + values: { name: entry.doc_id }, + } + ), + }); + }, + } + ); +} diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_delete_knowledge_base_entry.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_delete_knowledge_base_entry.ts index 3013a99bb3ed1..5d4f2b9e6eee4 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_delete_knowledge_base_entry.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_delete_knowledge_base_entry.ts @@ -20,16 +20,12 @@ export function useDeleteKnowledgeBaseEntry() { } = useKibana().services; const queryClient = useQueryClient(); - const observabilityAIAssistantApi = observabilityAIAssistant?.service.callApi; + const observabilityAIAssistantApi = observabilityAIAssistant.service.callApi; return useMutation( [REACT_QUERY_KEYS.CREATE_KB_ENTRIES], ({ id: entryId }) => { - if (!observabilityAIAssistantApi) { - return Promise.reject('Error with observabilityAIAssistantApi: API not found.'); - } - - return observabilityAIAssistantApi?.( + return observabilityAIAssistantApi( 'DELETE /internal/observability_ai_assistant/kb/entries/{entryId}', { signal: null, diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_get_knowledge_base_entries.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_get_knowledge_base_entries.ts index 167e2cfe71a56..c1a119d6a0f49 100644 --- a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_get_knowledge_base_entries.ts +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_get_knowledge_base_entries.ts @@ -20,13 +20,13 @@ export function useGetKnowledgeBaseEntries({ }) { const { observabilityAIAssistant } = useKibana().services; - const observabilityAIAssistantApi = observabilityAIAssistant?.service.callApi; + const observabilityAIAssistantApi = observabilityAIAssistant.service.callApi; const { isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery({ queryKey: [REACT_QUERY_KEYS.GET_KB_ENTRIES, query, sortBy, sortDirection], queryFn: async ({ signal }) => { - if (!observabilityAIAssistantApi || !signal) { - return Promise.reject('Error with observabilityAIAssistantApi: API not found.'); + if (!signal) { + throw new Error('Abort signal missing'); } return observabilityAIAssistantApi(`GET /internal/observability_ai_assistant/kb/entries`, { diff --git a/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_get_user_instructions.ts b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_get_user_instructions.ts new file mode 100644 index 0000000000000..ea59062c55c7e --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/hooks/use_get_user_instructions.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; +import { REACT_QUERY_KEYS } from '../constants'; +import { useKibana } from './use_kibana'; + +export function useGetUserInstructions() { + const { observabilityAIAssistant } = useKibana().services; + const observabilityAIAssistantApi = observabilityAIAssistant.service.callApi; + + const { isLoading, isError, isSuccess, isRefetching, data, refetch } = useQuery({ + queryKey: [REACT_QUERY_KEYS.GET_KB_USER_INSTRUCTIONS], + queryFn: async ({ signal }) => { + if (!signal) { + throw new Error('Abort signal missing'); + } + + return observabilityAIAssistantApi( + `GET /internal/observability_ai_assistant/kb/user_instructions`, + { signal } + ); + }, + }); + + return { + userInstructions: data?.userInstructions, + refetch, + isLoading, + isRefetching, + isSuccess, + isError, + }; +} 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 d415a5e6977c4..9ff0748793bc8 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 @@ -20,7 +20,7 @@ export function useImportKnowledgeBaseEntries() { notifications: { toasts }, } = useKibana().services; const queryClient = useQueryClient(); - const observabilityAIAssistantApi = observabilityAIAssistant?.service.callApi; + const observabilityAIAssistantApi = observabilityAIAssistant.service.callApi; return useMutation< void, @@ -36,11 +36,7 @@ export function useImportKnowledgeBaseEntries() { >( [REACT_QUERY_KEYS.IMPORT_KB_ENTRIES], ({ entries }) => { - if (!observabilityAIAssistantApi) { - return Promise.reject('Error with observabilityAIAssistantApi: API not found.'); - } - - return observabilityAIAssistantApi?.( + return observabilityAIAssistantApi( 'POST /internal/observability_ai_assistant/kb/entries/import', { signal: null, 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 new file mode 100644 index 0000000000000..8b12f842bf128 --- /dev/null +++ b/x-pack/plugins/observability_solution/observability_ai_assistant_management/public/routes/components/knowledge_base_edit_user_instruction_flyout.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiMarkdownEditor, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { v4 as uuidv4 } from 'uuid'; +import { useGetUserInstructions } from '../../hooks/use_get_user_instructions'; +import { useCreateKnowledgeBaseUserInstruction } from '../../hooks/use_create_knowledge_base_user_instruction'; + +export function KnowledgeBaseEditUserInstructionFlyout({ onClose }: { onClose: () => void }) { + const { userInstructions, isLoading: isFetching } = useGetUserInstructions(); + const { mutateAsync: createEntry, isLoading: isSaving } = useCreateKnowledgeBaseUserInstruction(); + const [newEntryText, setNewEntryText] = useState(''); + const [newEntryDocId, setNewEntryDocId] = useState(); + const isSubmitDisabled = newEntryText.trim() === ''; + + useEffect(() => { + const userInstruction = userInstructions?.find((entry) => !entry.public); + setNewEntryDocId(userInstruction?.doc_id); + setNewEntryText(userInstruction?.text ?? ''); + }, [userInstructions]); + + const handleSubmit = async () => { + await createEntry({ + entry: { + doc_id: newEntryDocId ?? uuidv4(), + text: newEntryText, + public: false, // limit user instructions to private (for now) + }, + }); + + onClose(); + }; + + return ( + + + +

+ {i18n.translate( + 'xpack.observabilityAiAssistantManagement.knowledgeBaseEditSystemPrompt.h2.editEntryLabel', + { defaultMessage: 'AI User Profile' } + )} +

+
+
+ + + + {i18n.translate( + 'xpack.observabilityAiAssistantManagement.knowledgeBaseEditSystemPromptFlyout.personalPromptTextLabel', + { + defaultMessage: + 'The AI User Profile will be appended to the system prompt. It is space-aware and will only be used for your prompts - not shared with other users.', + } + )} + + + + + + setNewEntryText(text)} + /> + + + + + + + + + {i18n.translate( + 'xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.cancelButtonEmptyLabel', + { defaultMessage: 'Cancel' } + )} + + + + + {i18n.translate( + 'xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.saveButtonLabel', + { defaultMessage: 'Save' } + )} + + + + +
+ ); +} 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 4257833dde714..de27f26f2561c 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 @@ -24,13 +24,14 @@ import { EuiScreenReaderOnly, } from '@elastic/eui'; import moment from 'moment'; -import type { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/common/types'; +import { KnowledgeBaseEntry } from '@kbn/observability-ai-assistant-plugin/public'; import { useGetKnowledgeBaseEntries } from '../../hooks/use_get_knowledge_base_entries'; import { categorizeEntries, KnowledgeBaseEntryCategory } from '../../helpers/categorize_entries'; import { KnowledgeBaseEditManualEntryFlyout } from './knowledge_base_edit_manual_entry_flyout'; import { KnowledgeBaseCategoryFlyout } from './knowledge_base_category_flyout'; import { KnowledgeBaseBulkImportFlyout } from './knowledge_base_bulk_import_flyout'; import { useKibana } from '../../hooks/use_kibana'; +import { KnowledgeBaseEditUserInstructionFlyout } from './knowledge_base_edit_user_instruction_flyout'; export function KnowledgeBaseTab() { const { uiSettings } = useKibana().services; @@ -175,11 +176,12 @@ export function KnowledgeBaseTab() { KnowledgeBaseEntryCategory | undefined >(); - const [flyoutOpenType, setFlyoutOpenType] = useState< - 'singleEntry' | 'bulkImport' | 'category' | undefined + const [newEntryFlyoutType, setNewEntryFlyoutType] = useState< + 'singleEntry' | 'bulkImport' | undefined >(); - const [newEntryPopoverOpen, setNewEntryPopoverOpen] = useState(false); + const [isNewEntryPopoverOpen, setIsNewEntryPopoverOpen] = useState(false); + const [isEditUserInstructionFlyoutOpen, setIsEditUserInstructionFlyoutOpen] = useState(false); const [query, setQuery] = useState(''); const [sortBy, setSortBy] = useState<'doc_id' | '@timestamp'>('doc_id'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); @@ -206,10 +208,6 @@ export function KnowledgeBaseTab() { } }; - const handleClickNewEntry = () => { - setNewEntryPopoverOpen(true); - }; - const handleChangeQuery = (e: React.ChangeEvent | undefined) => { setQuery(e?.currentTarget.value || ''); }; @@ -236,6 +234,7 @@ export function KnowledgeBaseTab() { )} /> + + + setIsEditUserInstructionFlyoutOpen(true)} + > + {i18n.translate( + 'xpack.observabilityAiAssistantManagement.knowledgeBaseTab.editInstructionsButtonLabel', + { defaultMessage: 'Edit AI User Profile' } + )} + + + setNewEntryPopoverOpen(false)} + isOpen={isNewEntryPopoverOpen} + closePopover={() => setIsNewEntryPopoverOpen(false)} button={ setIsNewEntryPopoverOpen((prevValue) => !prevValue)} > {i18n.translate( 'xpack.observabilityAiAssistantManagement.knowledgeBaseTab.newEntryButtonLabel', @@ -278,8 +290,8 @@ export function KnowledgeBaseTab() { icon="document" data-test-subj="knowledgeBaseSingleEntryContextMenuItem" onClick={() => { - setNewEntryPopoverOpen(false); - setFlyoutOpenType('singleEntry'); + setIsNewEntryPopoverOpen(false); + setNewEntryFlyoutType('singleEntry'); }} size="s" > @@ -293,8 +305,8 @@ export function KnowledgeBaseTab() { icon="documents" data-test-subj="knowledgeBaseBulkImportContextMenuItem" onClick={() => { - setNewEntryPopoverOpen(false); - setFlyoutOpenType('bulkImport'); + setIsNewEntryPopoverOpen(false); + setNewEntryFlyoutType('bulkImport'); }} > {i18n.translate( @@ -329,12 +341,18 @@ export function KnowledgeBaseTab() { - {flyoutOpenType === 'singleEntry' ? ( - setFlyoutOpenType(undefined)} /> + {isEditUserInstructionFlyoutOpen ? ( + setIsEditUserInstructionFlyoutOpen(false)} + /> + ) : null} + + {newEntryFlyoutType === 'singleEntry' ? ( + setNewEntryFlyoutType(undefined)} /> ) : null} - {flyoutOpenType === 'bulkImport' ? ( - setFlyoutOpenType(undefined)} /> + {newEntryFlyoutType === 'bulkImport' ? ( + setNewEntryFlyoutType(undefined)} /> ) : null} {selectedCategory ? ( diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index cc0f7067d89e0..43b621fff0144 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -11131,8 +11131,8 @@ "xpack.apm.serviceIcons.service": "Service", "xpack.apm.serviceIcons.serviceDetails.cloud.architecture": "Architecture", "xpack.apm.serviceIcons.serviceDetails.cloud.availabilityZoneLabel": "{zones, plural, =0 {Zone de disponibilité} one {Zone de disponibilité} other {Zones de disponibilité}} ", - "xpack.apm.serviceIcons.serviceDetails.cloud.faasTriggerTypeLabel": "{triggerTypes, plural, =0 {Type de déclencheur} one {Type de déclencheur} other {Types de déclencheurs}} ", "xpack.apm.serviceIcons.serviceDetails.cloud.functionNameLabel": "{functionNames, plural, =0 {Nom de fonction} one {Nom de fonction} other {Noms de fonction}} ", + "xpack.apm.serviceIcons.serviceDetails.cloud.faasTriggerTypeLabel": "{triggerTypes, plural, =0 {Type de déclencheur} one {Type de déclencheur} other {Types de déclencheurs}} ", "xpack.apm.serviceIcons.serviceDetails.cloud.machineTypesLabel": "{machineTypes, plural, =0{Type de machine} one {Type de machine} other {Types de machines}} ", "xpack.apm.serviceIcons.serviceDetails.cloud.projectIdLabel": "ID de projet", "xpack.apm.serviceIcons.serviceDetails.cloud.providerLabel": "Fournisseur cloud", @@ -27088,8 +27088,8 @@ "xpack.maps.source.esSearch.descendingLabel": "décroissant", "xpack.maps.source.esSearch.extentFilterLabel": "Filtre dynamique pour les données de la zone de carte visible", "xpack.maps.source.esSearch.fieldNotFoundMsg": "Impossible de trouver \"{fieldName}\" dans le modèle d'indexation \"{indexPatternName}\".", - "xpack.maps.source.esSearch.geofieldLabel": "Champ géospatial", "xpack.maps.source.esSearch.geoFieldLabel": "Champ géospatial", + "xpack.maps.source.esSearch.geofieldLabel": "Champ géospatial", "xpack.maps.source.esSearch.geoFieldTypeLabel": "Type de champ géospatial", "xpack.maps.source.esSearch.indexOverOneLengthEditError": "Votre vue de données pointe vers plusieurs index. Un seul index est autorisé par vue de données.", "xpack.maps.source.esSearch.indexZeroLengthEditError": "Votre vue de données ne pointe vers aucun index.", @@ -36554,8 +36554,8 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.maxAlertsFieldLessThanWarning": "Kibana ne permet qu'un maximum de {maxNumber} {maxNumber, plural, =1 {alerte} other {alertes}} par exécution de règle.", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.nameFieldRequiredError": "Nom obligatoire.", "xpack.securitySolution.detectionEngine.createRule.stepAboutrule.noteHelpText": "Ajouter un guide d'investigation sur les règles...", - "xpack.securitySolution.detectionEngine.createRule.stepAboutrule.setupHelpText": "Ajouter le guide de configuration de règle...", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.setupHelpText": "Fournissez des instructions sur les conditions préalables à la règle, telles que les intégrations requises, les étapes de configuration et tout ce qui est nécessaire au bon fonctionnement de la règle.", + "xpack.securitySolution.detectionEngine.createRule.stepAboutrule.setupHelpText": "Ajouter le guide de configuration de règle...", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.setupLabel": "Guide de configuration", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.tagFieldEmptyError": "Une balise ne doit pas être vide", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.threatIndicatorPathFieldEmptyError": "Le remplacement du préfixe d'indicateur ne peut pas être vide.", @@ -42193,8 +42193,8 @@ "xpack.slo.sloEmbeddable.config.sloSelector.placeholder": "Sélectionner un SLO", "xpack.slo.sloEmbeddable.displayName": "Aperçu du SLO", "xpack.slo.sloEmbeddable.overview.sloNotFoundText": "Le SLO a été supprimé. Vous pouvez supprimer sans risque le widget du tableau de bord.", - "xpack.slo.sloGridItem.targetFlexItemLabel": "Cible {target}", "xpack.slo.sLOGridItem.targetFlexItemLabel": "Cible {target}", + "xpack.slo.sloGridItem.targetFlexItemLabel": "Cible {target}", "xpack.slo.sloGroupConfiguration.customFiltersLabel": "Personnaliser le filtre", "xpack.slo.sloGroupConfiguration.customFiltersOptional": "Facultatif", "xpack.slo.sloGroupConfiguration.customFilterText": "Personnaliser le filtre", @@ -43640,8 +43640,8 @@ "xpack.stackConnectors.components.casesWebhookxpack.stackConnectors.components.casesWebhook.connectorTypeTitle": "Webhook - Données de gestion des cas", "xpack.stackConnectors.components.d3security.bodyCodeEditorAriaLabel": "Éditeur de code", "xpack.stackConnectors.components.d3security.bodyFieldLabel": "Corps", - "xpack.stackConnectors.components.d3security.connectorTypeTitle": "Données D3", "xpack.stackConnectors.components.d3Security.connectorTypeTitle": "D3 Security", + "xpack.stackConnectors.components.d3security.connectorTypeTitle": "Données D3", "xpack.stackConnectors.components.d3security.eventTypeFieldLabel": "Type d'événement", "xpack.stackConnectors.components.d3security.invalidActionText": "Nom d'action non valide.", "xpack.stackConnectors.components.d3security.requiredActionText": "L'action est requise.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 519c9f2a428a9..05a2e7a22a01c 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11120,8 +11120,8 @@ "xpack.apm.serviceIcons.service": "サービス", "xpack.apm.serviceIcons.serviceDetails.cloud.architecture": "アーキテクチャー", "xpack.apm.serviceIcons.serviceDetails.cloud.availabilityZoneLabel": "{zones, plural, other {可用性ゾーン}} ", - "xpack.apm.serviceIcons.serviceDetails.cloud.faasTriggerTypeLabel": "{triggerTypes, plural, other {トリガータイプ}} ", "xpack.apm.serviceIcons.serviceDetails.cloud.functionNameLabel": "{functionNames, plural, other {関数名}} ", + "xpack.apm.serviceIcons.serviceDetails.cloud.faasTriggerTypeLabel": "{triggerTypes, plural, other {トリガータイプ}} ", "xpack.apm.serviceIcons.serviceDetails.cloud.machineTypesLabel": "{machineTypes, plural, other {コンピュータータイプ} }\n ", "xpack.apm.serviceIcons.serviceDetails.cloud.projectIdLabel": "プロジェクト ID", "xpack.apm.serviceIcons.serviceDetails.cloud.providerLabel": "クラウドプロバイダー", @@ -27076,8 +27076,8 @@ "xpack.maps.source.esSearch.descendingLabel": "降順", "xpack.maps.source.esSearch.extentFilterLabel": "マップの表示範囲でデータを動的にフィルタリング", "xpack.maps.source.esSearch.fieldNotFoundMsg": "インデックスパターン''{indexPatternName}''に''{fieldName}''が見つかりません。", - "xpack.maps.source.esSearch.geofieldLabel": "地理空間フィールド", "xpack.maps.source.esSearch.geoFieldLabel": "地理空間フィールド", + "xpack.maps.source.esSearch.geofieldLabel": "地理空間フィールド", "xpack.maps.source.esSearch.geoFieldTypeLabel": "地理空間フィールドタイプ", "xpack.maps.source.esSearch.indexOverOneLengthEditError": "データビューは複数のインデックスを参照しています。データビューごとに1つのインデックスのみが許可されています。", "xpack.maps.source.esSearch.indexZeroLengthEditError": "データビューはどのインデックスも参照していません。", @@ -36537,8 +36537,8 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.maxAlertsFieldLessThanWarning": "Kibanaで許可される最大数は、1回の実行につき、{maxNumber} {maxNumber, plural, other {アラート}}です。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.nameFieldRequiredError": "名前が必要です。", "xpack.securitySolution.detectionEngine.createRule.stepAboutrule.noteHelpText": "ルール調査ガイドを追加...", - "xpack.securitySolution.detectionEngine.createRule.stepAboutrule.setupHelpText": "ルールセットアップガイドを追加...", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.setupHelpText": "必要な統合、構成ステップ、ルールが正常に動作するために必要な他のすべての項目といった、ルール前提条件に関する指示を入力します。", + "xpack.securitySolution.detectionEngine.createRule.stepAboutrule.setupHelpText": "ルールセットアップガイドを追加...", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.setupLabel": "セットアップガイド", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.tagFieldEmptyError": "タグを空にすることはできません", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.threatIndicatorPathFieldEmptyError": "インジケータープレフィックスの無効化を空にすることはできません", @@ -42176,8 +42176,8 @@ "xpack.slo.sloEmbeddable.config.sloSelector.placeholder": "SLOを選択", "xpack.slo.sloEmbeddable.displayName": "SLO概要", "xpack.slo.sloEmbeddable.overview.sloNotFoundText": "SLOが削除されました。ウィジェットをダッシュボードから安全に削除できます。", - "xpack.slo.sloGridItem.targetFlexItemLabel": "目標{target}", "xpack.slo.sLOGridItem.targetFlexItemLabel": "目標{target}", + "xpack.slo.sloGridItem.targetFlexItemLabel": "目標{target}", "xpack.slo.sloGroupConfiguration.customFiltersLabel": "カスタムフィルター", "xpack.slo.sloGroupConfiguration.customFiltersOptional": "オプション", "xpack.slo.sloGroupConfiguration.customFilterText": "カスタムフィルター", @@ -43619,8 +43619,8 @@ "xpack.stackConnectors.components.casesWebhookxpack.stackConnectors.components.casesWebhook.connectorTypeTitle": "Webフック - ケース管理データ", "xpack.stackConnectors.components.d3security.bodyCodeEditorAriaLabel": "コードエディター", "xpack.stackConnectors.components.d3security.bodyFieldLabel": "本文", - "xpack.stackConnectors.components.d3security.connectorTypeTitle": "D3データ", "xpack.stackConnectors.components.d3Security.connectorTypeTitle": "D3セキュリティ", + "xpack.stackConnectors.components.d3security.connectorTypeTitle": "D3データ", "xpack.stackConnectors.components.d3security.eventTypeFieldLabel": "イベントタイプ", "xpack.stackConnectors.components.d3security.invalidActionText": "無効なアクション名です。", "xpack.stackConnectors.components.d3security.requiredActionText": "アクションが必要です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 328008ebf3952..ab9ce8edafe35 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11139,8 +11139,8 @@ "xpack.apm.serviceIcons.service": "服务", "xpack.apm.serviceIcons.serviceDetails.cloud.architecture": "架构", "xpack.apm.serviceIcons.serviceDetails.cloud.availabilityZoneLabel": "{zones, plural, other {可用性区域}} ", - "xpack.apm.serviceIcons.serviceDetails.cloud.faasTriggerTypeLabel": "{triggerTypes, plural, other {触发类型}} ", "xpack.apm.serviceIcons.serviceDetails.cloud.functionNameLabel": "{functionNames, plural, other {功能名称}} ", + "xpack.apm.serviceIcons.serviceDetails.cloud.faasTriggerTypeLabel": "{triggerTypes, plural, other {触发类型}} ", "xpack.apm.serviceIcons.serviceDetails.cloud.machineTypesLabel": "{machineTypes, plural, other {机器类型}} ", "xpack.apm.serviceIcons.serviceDetails.cloud.projectIdLabel": "项目 ID", "xpack.apm.serviceIcons.serviceDetails.cloud.providerLabel": "云服务提供商", @@ -27108,8 +27108,8 @@ "xpack.maps.source.esSearch.descendingLabel": "降序", "xpack.maps.source.esSearch.extentFilterLabel": "在可见地图区域中动态筛留数据", "xpack.maps.source.esSearch.fieldNotFoundMsg": "在索引模式“{indexPatternName}”中找不到“{fieldName}”。", - "xpack.maps.source.esSearch.geofieldLabel": "地理空间字段", "xpack.maps.source.esSearch.geoFieldLabel": "地理空间字段", + "xpack.maps.source.esSearch.geofieldLabel": "地理空间字段", "xpack.maps.source.esSearch.geoFieldTypeLabel": "地理空间字段类型", "xpack.maps.source.esSearch.indexOverOneLengthEditError": "您的数据视图指向多个索引。每个数据视图只允许一个索引。", "xpack.maps.source.esSearch.indexZeroLengthEditError": "您的数据视图未指向任何索引。", @@ -36579,8 +36579,8 @@ "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.maxAlertsFieldLessThanWarning": "每次规则运行时,Kibana 最多只允许 {maxNumber} 个{maxNumber, plural, other {告警}}。", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.nameFieldRequiredError": "名称必填。", "xpack.securitySolution.detectionEngine.createRule.stepAboutrule.noteHelpText": "添加规则调查指南......", - "xpack.securitySolution.detectionEngine.createRule.stepAboutrule.setupHelpText": "添加规则设置指南......", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.setupHelpText": "提供有关规则先决条件的说明,如所需集成、配置步骤,以及规则正常运行所需的任何其他内容。", + "xpack.securitySolution.detectionEngine.createRule.stepAboutrule.setupHelpText": "添加规则设置指南......", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.setupLabel": "设置指南", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.tagFieldEmptyError": "标签不得为空", "xpack.securitySolution.detectionEngine.createRule.stepAboutRule.threatIndicatorPathFieldEmptyError": "指标前缀覆盖不得为空", @@ -42220,8 +42220,8 @@ "xpack.slo.sloEmbeddable.config.sloSelector.placeholder": "选择 SLO", "xpack.slo.sloEmbeddable.displayName": "SLO 概览", "xpack.slo.sloEmbeddable.overview.sloNotFoundText": "SLO 已删除。您可以放心从仪表板中删除小组件。", - "xpack.slo.sloGridItem.targetFlexItemLabel": "目标 {target}", "xpack.slo.sLOGridItem.targetFlexItemLabel": "目标 {target}", + "xpack.slo.sloGridItem.targetFlexItemLabel": "目标 {target}", "xpack.slo.sloGroupConfiguration.customFiltersLabel": "定制筛选", "xpack.slo.sloGroupConfiguration.customFiltersOptional": "可选", "xpack.slo.sloGroupConfiguration.customFilterText": "定制筛选", @@ -43667,8 +43667,8 @@ "xpack.stackConnectors.components.casesWebhookxpack.stackConnectors.components.casesWebhook.connectorTypeTitle": "Webhook - 案例管理数据", "xpack.stackConnectors.components.d3security.bodyCodeEditorAriaLabel": "代码编辑器", "xpack.stackConnectors.components.d3security.bodyFieldLabel": "正文", - "xpack.stackConnectors.components.d3security.connectorTypeTitle": "D3 数据", "xpack.stackConnectors.components.d3Security.connectorTypeTitle": "D3 Security", + "xpack.stackConnectors.components.d3security.connectorTypeTitle": "D3 数据", "xpack.stackConnectors.components.d3security.eventTypeFieldLabel": "事件类型", "xpack.stackConnectors.components.d3security.invalidActionText": "操作名称无效。", "xpack.stackConnectors.components.d3security.requiredActionText": "“操作”必填。", diff --git a/x-pack/test/functional/apps/search_playground/playground_overview.ess.ts b/x-pack/test/functional/apps/search_playground/playground_overview.ess.ts index a5013a92e72c0..31f8b32eec594 100644 --- a/x-pack/test/functional/apps/search_playground/playground_overview.ess.ts +++ b/x-pack/test/functional/apps/search_playground/playground_overview.ess.ts @@ -5,7 +5,6 @@ * 2.0. */ -import type OpenAI from 'openai'; import { FtrProviderContext } from '../../ftr_provider_context'; import { createOpenAIConnector } from './utils/create_openai_connector'; import { MachineLearningCommonAPIProvider } from '../../services/ml/common_api'; @@ -142,9 +141,7 @@ export default function (ftrContext: FtrProviderContext) { const conversationInterceptor = proxy.intercept( 'conversation', (body) => - (JSON.parse(body) as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming).tools?.find( - (fn) => fn.function.name === 'title_conversation' - ) === undefined + body.tools?.find((fn) => fn.function.name === 'title_conversation') === undefined ); await pageObjects.searchPlayground.PlaygroundChatPage.sendQuestion(); diff --git a/x-pack/test/observability_ai_assistant_api_integration/common/action_connectors.ts b/x-pack/test/observability_ai_assistant_api_integration/common/action_connectors.ts new file mode 100644 index 0000000000000..b577ef03c5eb6 --- /dev/null +++ b/x-pack/test/observability_ai_assistant_api_integration/common/action_connectors.ts @@ -0,0 +1,63 @@ +/* + * 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 { ToolingLog } from '@kbn/tooling-log'; +import { Agent } from 'supertest'; + +export async function deleteActionConnector({ + supertest, + connectorId, + log, +}: { + supertest: Agent; + connectorId: string; + log: ToolingLog; +}) { + try { + await supertest + .delete(`/api/actions/connector/${connectorId}`) + .set('kbn-xsrf', 'foo') + .expect(204); + } catch (e) { + log.error(`Failed to delete action connector with id ${connectorId} due to: ${e}`); + throw e; + } +} + +export async function createProxyActionConnector({ + log, + supertest, + port, +}: { + log: ToolingLog; + supertest: Agent; + port: number; +}) { + try { + const res = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'OpenAI Proxy', + connector_type_id: '.gen-ai', + config: { + apiProvider: 'OpenAI', + apiUrl: `http://localhost:${port}`, + }, + secrets: { + apiKey: 'my-api-key', + }, + }) + .expect(200); + + const connectorId = res.body.id as string; + return connectorId; + } catch (e) { + log.error(`Failed to create action connector due to: ${e}`); + throw e; + } +} 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 acd6a6287d446..198fcefdc2bc8 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 @@ -51,6 +51,9 @@ export function createObservabilityAIAssistantAPIConfig({ servers, services: { ...services, + getScopedApiClientForUsername: () => { + return (username: string) => getScopedApiClient(kibanaServer, username); + }, apmSynthtraceEsClient: (context: InheritedFtrProviderContext) => getApmSynthtraceEsClient(context, apmSynthtraceKibanaClient), observabilityAIAssistantAPIClient: async () => { diff --git a/x-pack/test/observability_ai_assistant_api_integration/common/create_llm_proxy.ts b/x-pack/test/observability_ai_assistant_api_integration/common/create_llm_proxy.ts index 2d01c7692e54f..7337fb8f6e5b2 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/common/create_llm_proxy.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/common/create_llm_proxy.ts @@ -9,20 +9,26 @@ import { ToolingLog } from '@kbn/tooling-log'; import getPort from 'get-port'; import http, { type Server } from 'http'; import { once, pull } from 'lodash'; +import OpenAI from 'openai'; +import { TITLE_CONVERSATION_FUNCTION_NAME } from '@kbn/observability-ai-assistant-plugin/server/service/client/operators/get_generated_title'; import { createOpenAiChunk } from './create_openai_chunk'; type Request = http.IncomingMessage; type Response = http.ServerResponse & { req: http.IncomingMessage }; -type RequestHandler = (request: Request, response: Response, body: string) => void; +type RequestHandler = ( + request: Request, + response: Response, + body: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming +) => void; interface RequestInterceptor { name: string; - when: (body: string) => boolean; + when: (body: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming) => boolean; } export interface LlmResponseSimulator { - body: string; + body: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming; status: (code: number) => Promise; next: ( msg: @@ -40,10 +46,13 @@ export interface LlmResponseSimulator { export class LlmProxy { server: Server; + interval: NodeJS.Timeout; interceptors: Array = []; constructor(private readonly port: number, private readonly log: ToolingLog) { + this.interval = setInterval(() => this.log.debug(`LLM proxy listening on port ${port}`), 1000); + this.server = http .createServer() .on('request', async (request, response) => { @@ -62,7 +71,9 @@ export class LlmProxy { } } - response.writeHead(500, 'No interceptors found to handle request: ' + request.url); + const errorMessage = `No interceptors found to handle request: ${request.method} ${request.url}`; + this.log.error(`${errorMessage}. Messages: ${JSON.stringify(body.messages, null, 2)}`); + response.writeHead(500, { errorMessage, messages: JSON.stringify(body.messages) }); response.end(); }) .on('error', (error) => { @@ -80,6 +91,8 @@ export class LlmProxy { } close() { + this.log.debug(`Closing LLM Proxy on port ${this.port}`); + clearInterval(this.interval); this.server.close(); } @@ -87,6 +100,27 @@ export class LlmProxy { return Promise.all(this.interceptors); } + interceptConversation({ + name = 'default_interceptor_conversation_name', + response, + }: { + name?: string; + response: string; + }) { + return this.intercept(name, (body) => !isFunctionTitleRequest(body), response); + } + + interceptConversationTitle(title: string) { + return this.intercept('conversation_title', (body) => isFunctionTitleRequest(body), [ + { + function_call: { + name: TITLE_CONVERSATION_FUNCTION_NAME, + arguments: JSON.stringify({ title }), + }, + }, + ]); + } + intercept< TResponseChunks extends Array> | string | undefined = undefined >( @@ -175,11 +209,13 @@ export class LlmProxy { export async function createLlmProxy(log: ToolingLog) { const port = await getPort({ port: getPort.makeRange(9000, 9100) }); - + log.debug(`Starting LLM Proxy on port ${port}`); return new LlmProxy(port, log); } -async function getRequestBody(request: http.IncomingMessage): Promise { +async function getRequestBody( + request: http.IncomingMessage +): Promise { return new Promise((resolve, reject) => { let data = ''; @@ -188,7 +224,7 @@ async function getRequestBody(request: http.IncomingMessage): Promise { }); request.on('close', () => { - resolve(data); + resolve(JSON.parse(data)); }); request.on('error', (error) => { @@ -196,3 +232,9 @@ async function getRequestBody(request: http.IncomingMessage): Promise { }); }); } + +export function isFunctionTitleRequest(body: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming) { + return ( + body.tools?.find((fn) => fn.function.name === TITLE_CONVERSATION_FUNCTION_NAME) !== undefined + ); +} diff --git a/x-pack/test/observability_ai_assistant_api_integration/common/observability_ai_assistant_api_client.ts b/x-pack/test/observability_ai_assistant_api_integration/common/observability_ai_assistant_api_client.ts index 005815b38057a..ced1d24004e9b 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/common/observability_ai_assistant_api_client.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/common/observability_ai_assistant_api_client.ts @@ -15,9 +15,8 @@ import supertest from 'supertest'; import { Subtract } from 'utility-types'; import { format, UrlObject } from 'url'; import { kbnTestConfig } from '@kbn/test'; -import { User } from './users/users'; -export async function getScopedApiClient(kibanaServer: UrlObject, username: User['username']) { +export function getScopedApiClient(kibanaServer: UrlObject, username: string) { const { password } = kbnTestConfig.getUrlParts(); const baseUrlWithAuth = format({ ...kibanaServer, @@ -27,6 +26,9 @@ export async function getScopedApiClient(kibanaServer: UrlObject, username: User return createObservabilityAIAssistantApiClient(supertest(baseUrlWithAuth)); } +export type ObservabilityAIAssistantApiClient = ReturnType< + typeof createObservabilityAIAssistantApiClient +>; export function createObservabilityAIAssistantApiClient(st: supertest.Agent) { return ( options: { diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/chat/chat.spec.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/chat/chat.spec.ts index a9cf749b3d761..ffd1ad09c7ccd 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/chat/chat.spec.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/chat/chat.spec.ts @@ -10,6 +10,7 @@ import { MessageRole, type Message } from '@kbn/observability-ai-assistant-plugi import { PassThrough } from 'stream'; import { createLlmProxy, LlmProxy } from '../../common/create_llm_proxy'; import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { createProxyActionConnector, deleteActionConnector } from '../../common/action_connectors'; export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -41,28 +42,12 @@ export default function ApiTest({ getService }: FtrProviderContext) { before(async () => { proxy = await createLlmProxy(log); - - const response = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: 'OpenAI', - connector_type_id: '.gen-ai', - config: { - apiProvider: 'OpenAI', - apiUrl: `http://localhost:${proxy.getPort()}`, - }, - secrets: { - apiKey: 'my-api-key', - }, - }) - .expect(200); - - connectorId = response.body.id; + connectorId = await createProxyActionConnector({ supertest, log, port: proxy.getPort() }); }); - after(() => { + after(async () => { proxy.close(); + await deleteActionConnector({ supertest, connectorId, log }); }); it("returns a 4xx if the connector doesn't exist", async () => { @@ -195,12 +180,5 @@ export default function ApiTest({ getService }: FtrProviderContext) { `Token limit reached. Token limit is 8192, but the current conversation has 11036 tokens.` ); }); - - after(async () => { - await supertest - .delete(`/api/actions/connector/${connectorId}`) - .set('kbn-xsrf', 'foo') - .expect(204); - }); }); } 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 e159475f50523..1a79c3799f59b 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 @@ -7,7 +7,7 @@ import { Response } from 'supertest'; import { MessageRole, type Message } from '@kbn/observability-ai-assistant-plugin/common'; import { omit, pick } from 'lodash'; -import { PassThrough, Readable } from 'stream'; +import { PassThrough } from 'stream'; import expect from '@kbn/expect'; import { ChatCompletionChunkEvent, @@ -17,11 +17,21 @@ import { StreamingChatResponseEvent, StreamingChatResponseEventType, } from '@kbn/observability-ai-assistant-plugin/common/conversation_complete'; -import type OpenAI from 'openai'; import { ObservabilityAIAssistantScreenContextRequest } from '@kbn/observability-ai-assistant-plugin/common/types'; -import { createLlmProxy, LlmProxy, LlmResponseSimulator } from '../../common/create_llm_proxy'; +import { + createLlmProxy, + isFunctionTitleRequest, + LlmProxy, + LlmResponseSimulator, +} from '../../common/create_llm_proxy'; import { createOpenAiChunk } from '../../common/create_openai_chunk'; import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + decodeEvents, + getConversationCreatedEvent, + getConversationUpdatedEvent, +} from '../conversations/helpers'; +import { createProxyActionConnector, deleteActionConnector } from '../../common/action_connectors'; export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -105,33 +115,12 @@ export default function ApiTest({ getService }: FtrProviderContext) { before(async () => { proxy = await createLlmProxy(log); - - const response = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: 'OpenAI Proxy', - connector_type_id: '.gen-ai', - config: { - apiProvider: 'OpenAI', - apiUrl: `http://localhost:${proxy.getPort()}`, - }, - secrets: { - apiKey: 'my-api-key', - }, - }) - .expect(200); - - connectorId = response.body.id; + connectorId = await createProxyActionConnector({ supertest, log, port: proxy.getPort() }); }); after(async () => { - await supertest - .delete(`/api/actions/connector/${connectorId}`) - .set('kbn-xsrf', 'foo') - .expect(204); - proxy.close(); + await deleteActionConnector({ supertest, connectorId, log }); }); it('returns a streaming response from the server', async () => { @@ -390,20 +379,6 @@ export default function ApiTest({ getService }: FtrProviderContext) { let conversationCreatedEvent: ConversationCreateEvent; let conversationUpdatedEvent: ConversationUpdateEvent; - function getConversationCreatedEvent(body: Readable | string) { - const decodedEvents = decodeEvents(body); - return decodedEvents.find( - (event) => event.type === StreamingChatResponseEventType.ConversationCreate - ) as ConversationCreateEvent; - } - - function getConversationUpdatedEvent(body: Readable | string) { - const decodedEvents = decodeEvents(body); - return decodedEvents.find( - (event) => event.type === StreamingChatResponseEventType.ConversationUpdate - ) as ConversationUpdateEvent; - } - before(async () => { proxy .intercept('conversation_title', (body) => isFunctionTitleRequest(body), [ @@ -511,16 +486,3 @@ export default function ApiTest({ getService }: FtrProviderContext) { it.skip('executes a function', async () => {}); }); } - -function decodeEvents(body: Readable | string) { - return String(body) - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => JSON.parse(line) as StreamingChatResponseEvent); -} - -function isFunctionTitleRequest(body: string) { - const parsedBody = JSON.parse(body) as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming; - return parsedBody.tools?.find((fn) => fn.function.name === 'title_conversation') !== undefined; -} diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/complete/functions/elasticsearch.spec.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/complete/functions/elasticsearch.spec.ts index 559f21e944011..961afefb0748f 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/complete/functions/elasticsearch.spec.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/complete/functions/elasticsearch.spec.ts @@ -10,14 +10,13 @@ import expect from '@kbn/expect'; import { apm, timerange } from '@kbn/apm-synthtrace-client'; import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace'; import { ELASTICSEARCH_FUNCTION_NAME } from '@kbn/observability-ai-assistant-plugin/server/functions/elasticsearch'; -import { LlmProxy } from '../../../common/create_llm_proxy'; +import { LlmProxy, createLlmProxy } from '../../../common/create_llm_proxy'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { getMessageAddedEvents, invokeChatCompleteWithFunctionRequest } from './helpers'; import { - createLLMProxyConnector, - deleteLLMProxyConnector, - getMessageAddedEvents, - invokeChatCompleteWithFunctionRequest, -} from './helpers'; + createProxyActionConnector, + deleteActionConnector, +} from '../../../common/action_connectors'; export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -31,7 +30,12 @@ export default function ApiTest({ getService }: FtrProviderContext) { let events: MessageAddEvent[]; before(async () => { - ({ connectorId, proxy } = await createLLMProxyConnector({ log, supertest })); + proxy = await createLlmProxy(log); + connectorId = await createProxyActionConnector({ supertest, log, port: proxy.getPort() }); + + // intercept the LLM request and return a fixed response + proxy.intercept('conversation', () => true, 'Hello from LLM Proxy').completeAfterIntercept(); + await generateApmData(apmSynthtraceEsClient); const responseBody = await invokeChatCompleteWithFunctionRequest({ @@ -63,7 +67,8 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); after(async () => { - await deleteLLMProxyConnector({ supertest, connectorId, proxy }); + proxy.close(); + await deleteActionConnector({ supertest, connectorId, log }); await apmSynthtraceEsClient.clean(); }); 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 f4323c96e3eff..a32c22abcf7aa 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 @@ -11,11 +11,8 @@ import { MessageRole, StreamingChatResponseEvent, } from '@kbn/observability-ai-assistant-plugin/common'; -import { ToolingLog } from '@kbn/tooling-log'; -import { Agent } from 'supertest'; import { Readable } from 'stream'; import { CreateTest } from '../../../common/config'; -import { createLlmProxy, LlmProxy } from '../../../common/create_llm_proxy'; function decodeEvents(body: Readable | string) { return String(body) @@ -31,57 +28,6 @@ export function getMessageAddedEvents(body: Readable | string) { ); } -export async function createLLMProxyConnector({ - log, - supertest, -}: { - log: ToolingLog; - supertest: Agent; -}) { - const proxy = await createLlmProxy(log); - - // intercept the LLM request and return a fixed response - proxy.intercept('conversation', () => true, 'Hello from LLM Proxy').completeAfterIntercept(); - - const response = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: 'OpenAI Proxy', - connector_type_id: '.gen-ai', - config: { - apiProvider: 'OpenAI', - apiUrl: `http://localhost:${proxy.getPort()}`, - }, - secrets: { - apiKey: 'my-api-key', - }, - }) - .expect(200); - - return { - proxy, - connectorId: response.body.id, - }; -} - -export async function deleteLLMProxyConnector({ - supertest, - connectorId, - proxy, -}: { - supertest: Agent; - connectorId: string; - proxy: LlmProxy; -}) { - await supertest - .delete(`/api/actions/connector/${connectorId}`) - .set('kbn-xsrf', 'foo') - .expect(204); - - proxy.close(); -} - export async function invokeChatCompleteWithFunctionRequest({ connectorId, observabilityAIAssistantAPIClient, 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 0cda45a1a4253..8f312219f2e49 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 @@ -7,13 +7,13 @@ import { MessageRole } from '@kbn/observability-ai-assistant-plugin/common'; import expect from '@kbn/expect'; -import { LlmProxy } from '../../../common/create_llm_proxy'; +import { LlmProxy, createLlmProxy } from '../../../common/create_llm_proxy'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { invokeChatCompleteWithFunctionRequest } from './helpers'; import { - createLLMProxyConnector, - deleteLLMProxyConnector, - invokeChatCompleteWithFunctionRequest, -} from './helpers'; + createProxyActionConnector, + deleteActionConnector, +} from '../../../common/action_connectors'; export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -26,7 +26,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { let connectorId: string; before(async () => { - ({ connectorId, proxy } = await createLLMProxyConnector({ log, supertest })); + proxy = await createLlmProxy(log); + connectorId = await createProxyActionConnector({ supertest, log, port: proxy.getPort() }); + + // intercept the LLM request and return a fixed response + proxy.intercept('conversation', () => true, 'Hello from LLM Proxy').completeAfterIntercept(); await invokeChatCompleteWithFunctionRequest({ connectorId, @@ -48,7 +52,8 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); after(async () => { - await deleteLLMProxyConnector({ supertest, connectorId, proxy }); + proxy.close(); + await deleteActionConnector({ supertest, connectorId, log }); }); it('persists entry in knowledge base', async () => { 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 d334251d9114e..e8363ba41513b 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 @@ -8,10 +8,12 @@ import expect from '@kbn/expect'; import type { Agent as SuperTestAgent } from 'supertest'; import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { createProxyActionConnector, deleteActionConnector } from '../../common/action_connectors'; export default function ApiTest({ getService }: FtrProviderContext) { const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient'); const supertest = getService('supertest'); + const log = getService('log'); describe('List connectors', () => { before(async () => { @@ -39,21 +41,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); it("returns the gen ai connector if it's been created", async () => { - const connectorCreateResponse = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: 'OpenAI', - connector_type_id: '.gen-ai', - config: { - apiProvider: 'OpenAI', - apiUrl: 'http://localhost:9200', - }, - secrets: { - apiKey: 'my-api-key', - }, - }) - .expect(200); + const connectorId = await createProxyActionConnector({ supertest, log, port: 1234 }); const res = await observabilityAIAssistantAPIClient.editorUser({ endpoint: 'GET /internal/observability_ai_assistant/connectors', @@ -61,12 +49,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { expect(res.body.length).to.be(1); - const connectorId = connectorCreateResponse.body.id; - - await supertest - .delete(`/api/actions/connector/${connectorId}`) - .set('kbn-xsrf', 'foo') - .expect(204); + await deleteActionConnector({ supertest, connectorId, log }); }); }); } diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/conversations/helpers.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/conversations/helpers.ts new file mode 100644 index 0000000000000..2e5d359ed1e78 --- /dev/null +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/conversations/helpers.ts @@ -0,0 +1,52 @@ +/* + * 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 { Readable } from 'stream'; +import { + ConversationCreateEvent, + ConversationUpdateEvent, + StreamingChatResponseEvent, + StreamingChatResponseEventType, +} from '@kbn/observability-ai-assistant-plugin/common/conversation_complete'; + +export function decodeEvents(body: Readable | string) { + return String(body) + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => JSON.parse(line) as StreamingChatResponseEvent); +} + +export function getConversationCreatedEvent(body: Readable | string) { + const decodedEvents = decodeEvents(body); + const conversationCreatedEvent = decodedEvents.find( + (event) => event.type === StreamingChatResponseEventType.ConversationCreate + ) as ConversationCreateEvent; + + if (!conversationCreatedEvent) { + throw new Error( + `No conversation created event found: ${JSON.stringify(decodedEvents, null, 2)}` + ); + } + + return conversationCreatedEvent; +} + +export function getConversationUpdatedEvent(body: Readable | string) { + const decodedEvents = decodeEvents(body); + const conversationUpdatedEvent = decodedEvents.find( + (event) => event.type === StreamingChatResponseEventType.ConversationUpdate + ) as ConversationUpdateEvent; + + if (!conversationUpdatedEvent) { + throw new Error( + `No conversation created event found: ${JSON.stringify(decodedEvents, null, 2)}` + ); + } + + return conversationUpdatedEvent; +} diff --git a/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/helpers.ts b/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/helpers.ts index 1d9a9170e56ea..1818203f737c0 100644 --- a/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/helpers.ts +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/helpers.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { Client } from '@elastic/elasticsearch'; import { MachineLearningProvider } from '../../../api_integration/services/ml'; import { SUPPORTED_TRAINED_MODELS } from '../../../functional/services/ml/api'; @@ -23,9 +24,32 @@ export async function createKnowledgeBaseModel(ml: ReturnType) { await ml.api.stopTrainedModelDeploymentES(TINY_ELSER.id, true); await ml.api.deleteTrainedModelES(TINY_ELSER.id); await ml.api.cleanMlIndices(); await ml.testResources.cleanMLSavedObjects(); } + +export async function clearKnowledgeBase(es: Client) { + const KB_INDEX = '.kibana-observability-ai-assistant-kb-*'; + + return es.deleteByQuery({ + index: KB_INDEX, + conflicts: 'proceed', + query: { match_all: {} }, + refresh: true, + }); +} + +export async function clearConversations(es: Client) { + const KB_INDEX = '.kibana-observability-ai-assistant-conversations-*'; + + return es.deleteByQuery({ + index: KB_INDEX, + conflicts: 'proceed', + query: { match_all: {} }, + refresh: true, + }); +} 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 74ba11ce2eb9e..c8881e82e43bb 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 @@ -7,19 +7,13 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { createKnowledgeBaseModel, deleteKnowledgeBaseModel } from './helpers'; - -interface KnowledgeBaseEntry { - id: string; - text: string; -} +import { clearKnowledgeBase, createKnowledgeBaseModel, deleteKnowledgeBaseModel } from './helpers'; export default function ApiTest({ getService }: FtrProviderContext) { const ml = getService('ml'); const es = getService('es'); const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient'); - const KB_INDEX = '.kibana-observability-ai-assistant-kb-*'; describe('Knowledge base', () => { before(async () => { @@ -106,10 +100,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { }, }) .expect(200); - expect( - res.body.entries.filter((entry: KnowledgeBaseEntry) => entry.id.startsWith('my-doc-id')) - .length - ).to.eql(0); + expect(res.body.entries.filter((entry) => entry.id.startsWith('my-doc-id')).length).to.eql( + 0 + ); }); it('returns 500 on delete not found', async () => { @@ -126,20 +119,12 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); describe('when managing multiple entries', () => { before(async () => { - es.deleteByQuery({ - index: KB_INDEX, - conflicts: 'proceed', - query: { match_all: {} }, - }); + await clearKnowledgeBase(es); }); afterEach(async () => { - es.deleteByQuery({ - index: KB_INDEX, - conflicts: 'proceed', - query: { match_all: {} }, - }); + await clearKnowledgeBase(es); }); - const knowledgeBaseEntries: KnowledgeBaseEntry[] = [ + const knowledgeBaseEntries = [ { id: 'my_doc_a', text: 'My content a', @@ -173,10 +158,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }, }) .expect(200); - expect( - res.body.entries.filter((entry: KnowledgeBaseEntry) => entry.id.startsWith('my_doc')) - .length - ).to.eql(3); + expect(res.body.entries.filter((entry) => entry.id.startsWith('my_doc')).length).to.eql(3); }); it('allows sorting', async () => { @@ -200,9 +182,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }) .expect(200); - const entries = res.body.entries.filter((entry: KnowledgeBaseEntry) => - entry.id.startsWith('my_doc') - ); + 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'); @@ -221,9 +201,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }) .expect(200); - const entriesAsc = resAsc.body.entries.filter((entry: KnowledgeBaseEntry) => - entry.id.startsWith('my_doc') - ); + 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'); 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 new file mode 100644 index 0000000000000..4cad8079dc0b2 --- /dev/null +++ b/x-pack/test/observability_ai_assistant_api_integration/tests/knowledge_base/knowledge_base_user_instructions.spec.ts @@ -0,0 +1,319 @@ +/* + * 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 { 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'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + clearConversations, + clearKnowledgeBase, + createKnowledgeBaseModel, + deleteKnowledgeBaseModel, +} from './helpers'; +import { getConversationCreatedEvent } from '../conversations/helpers'; +import { LlmProxy, createLlmProxy } from '../../common/create_llm_proxy'; +import { createProxyActionConnector, deleteActionConnector } from '../../common/action_connectors'; + +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'); + + 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' }) + .expect(200); + }); + + after(async () => { + await deleteKnowledgeBaseModel(ml); + await security.user.delete(userJohn); + await clearKnowledgeBase(es); + await clearConversations(es); + }); + + describe('when creating private and public user instructions', () => { + before(async () => { + await clearKnowledgeBase(es); + + const promises = [ + { + username: 'editor', + isPublic: true, + }, + { + username: 'editor', + isPublic: false, + }, + { + username: userJohn, + isPublic: true, + }, + { + username: userJohn, + isPublic: false, + }, + ].map(async ({ username, isPublic }) => { + const visibility = isPublic ? 'Public' : 'Private'; + await getScopedApiClientForUsername(username)({ + endpoint: 'PUT /internal/observability_ai_assistant/kb/user_instructions', + params: { + body: { + id: `${visibility.toLowerCase()}-doc-from-${username}`, + text: `${visibility} user instruction from "${username}"`, + public: isPublic, + }, + }, + }).expect(200); + }); + + await Promise.all(promises); + }); + + it('"editor" can retrieve their own private instructions and the public instruction', async () => { + const res = await observabilityAIAssistantAPIClient.editorUser({ + endpoint: 'GET /internal/observability_ai_assistant/kb/user_instructions', + }); + const instructions = res.body.userInstructions; + + const sortByDocId = (data: any) => sortBy(data, 'doc_id'); + expect(sortByDocId(instructions)).to.eql( + sortByDocId([ + { + doc_id: 'private-doc-from-editor', + public: false, + text: 'Private user instruction from "editor"', + }, + { + doc_id: 'public-doc-from-editor', + public: true, + text: 'Public user instruction from "editor"', + }, + { + doc_id: 'public-doc-from-john', + public: true, + text: 'Public user instruction from "john"', + }, + ]) + ); + }); + + it('"john" can retrieve their own private instructions and the public instruction', async () => { + const res = await getScopedApiClientForUsername(userJohn)({ + endpoint: 'GET /internal/observability_ai_assistant/kb/user_instructions', + }); + const instructions = res.body.userInstructions; + + const sortByDocId = (data: any) => sortBy(data, 'doc_id'); + expect(sortByDocId(instructions)).to.eql( + sortByDocId([ + { + doc_id: 'public-doc-from-editor', + public: true, + text: 'Public user instruction from "editor"', + }, + { + doc_id: 'public-doc-from-john', + public: true, + text: 'Public user instruction from "john"', + }, + { + doc_id: 'private-doc-from-john', + public: false, + text: 'Private user instruction from "john"', + }, + ]) + ); + }); + }); + + describe('when updating an existing user instructions', () => { + before(async () => { + await clearKnowledgeBase(es); + + await observabilityAIAssistantAPIClient + .editorUser({ + endpoint: 'PUT /internal/observability_ai_assistant/kb/user_instructions', + params: { + body: { + id: 'doc-to-update', + text: 'Initial text', + public: true, + }, + }, + }) + .expect(200); + + await observabilityAIAssistantAPIClient + .editorUser({ + endpoint: 'PUT /internal/observability_ai_assistant/kb/user_instructions', + params: { + body: { + id: 'doc-to-update', + text: 'Updated text', + public: false, + }, + }, + }) + .expect(200); + }); + + it('updates the user instruction', async () => { + const res = await observabilityAIAssistantAPIClient.editorUser({ + endpoint: 'GET /internal/observability_ai_assistant/kb/user_instructions', + }); + const instructions = res.body.userInstructions; + + expect(instructions).to.eql([ + { + doc_id: 'doc-to-update', + text: 'Updated text', + public: false, + }, + ]); + }); + }); + + describe('when a user instruction exist and a conversation is created', () => { + let proxy: LlmProxy; + let connectorId: string; + + const userInstructionText = + 'Be polite and use language that is easy to understand. Never disagree with the user.'; + + async function getConversationForUser(username: string) { + const apiClient = getScopedApiClientForUsername(username); + + // the user instruction is always created by "editor" user + await observabilityAIAssistantAPIClient + .editorUser({ + endpoint: 'PUT /internal/observability_ai_assistant/kb/user_instructions', + params: { + body: { + id: 'private-instruction-about-language', + text: userInstructionText, + public: false, + }, + }, + }) + .expect(200); + + const interceptPromises = [ + proxy.interceptConversationTitle('LLM-generated title').completeAfterIntercept(), + proxy + .interceptConversation({ name: 'conversation', response: 'I, the LLM, hear you!' }) + .completeAfterIntercept(), + ]; + + const messages: Message[] = [ + { + '@timestamp': new Date().toISOString(), + message: { + role: MessageRole.System, + content: 'You are a helpful assistant', + }, + }, + { + '@timestamp': new Date().toISOString(), + message: { + role: MessageRole.User, + content: 'Today we will be testing user instructions!', + }, + }, + ]; + + const createResponse = await apiClient({ + endpoint: 'POST /internal/observability_ai_assistant/chat/complete', + params: { + body: { + messages, + connectorId, + persist: true, + screenContexts: [], + }, + }, + }).expect(200); + + await proxy.waitForAllInterceptorsSettled(); + const conversationCreatedEvent = getConversationCreatedEvent(createResponse.body); + const conversationId = conversationCreatedEvent.conversation.id; + + const res = await apiClient({ + endpoint: 'GET /internal/observability_ai_assistant/conversation/{conversationId}', + params: { + path: { + conversationId, + }, + }, + }); + + // wait for all interceptors to be settled + await Promise.all(interceptPromises); + + const conversation = res.body; + return conversation; + } + + before(async () => { + proxy = await createLlmProxy(log); + connectorId = await createProxyActionConnector({ supertest, log, port: proxy.getPort() }); + }); + + after(async () => { + proxy.close(); + await deleteActionConnector({ supertest, connectorId, log }); + }); + + it('adds the instruction to the system prompt', async () => { + const conversation = await getConversationForUser('editor'); + const systemMessage = conversation.messages.find( + (message) => message.message.role === MessageRole.System + )!; + expect(systemMessage.message.content).to.contain(userInstructionText); + }); + + it('does not add the instruction to the context', async () => { + const conversation = await getConversationForUser('editor'); + const contextMessage = conversation.messages.find( + (message) => message.message.name === CONTEXT_FUNCTION_NAME + ); + + // there should be no suggestions with the user instruction + expect(contextMessage?.message.content).to.not.contain(userInstructionText); + expect(contextMessage?.message.data).to.not.contain(userInstructionText); + + // there should be no suggestions at all + expect(JSON.parse(contextMessage?.message.data!).suggestions.length).to.be(0); + }); + + it('does not add the instruction conversation for other users', async () => { + const conversation = await getConversationForUser('john'); + const systemMessage = conversation.messages.find( + (message) => message.message.role === MessageRole.System + )!; + + expect(systemMessage.message.content).to.not.contain(userInstructionText); + expect(conversation.messages.length).to.be(5); + }); + }); + }); +} 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 f496e42868ac8..344b387bdde38 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 @@ -10,18 +10,23 @@ import { MessageRole, type Message, } from '@kbn/observability-ai-assistant-plugin/common'; -import { StreamingChatResponseEvent } from '@kbn/observability-ai-assistant-plugin/common/conversation_complete'; +import { type StreamingChatResponseEvent } from '@kbn/observability-ai-assistant-plugin/common/conversation_complete'; import { pick } from 'lodash'; import type OpenAI from 'openai'; -import { Response } from 'supertest'; -import { createLlmProxy, LlmProxy, LlmResponseSimulator } from '../../common/create_llm_proxy'; +import { type AdHocInstruction } from '@kbn/observability-ai-assistant-plugin/common/types'; +import { + createLlmProxy, + isFunctionTitleRequest, + LlmProxy, + LlmResponseSimulator, +} from '../../common/create_llm_proxy'; import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { createProxyActionConnector, deleteActionConnector } from '../../common/action_connectors'; export default function ApiTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const log = getService('log'); - - const PUBLIC_COMPLETE_API_URL = `/api/observability_ai_assistant/chat/complete`; + const observabilityAIAssistantAPIClient = getService('observabilityAIAssistantAPIClient'); const messages: Message[] = [ { @@ -46,8 +51,8 @@ export default function ApiTest({ getService }: FtrProviderContext) { interface RequestOptions { actions?: Array>; - instructions?: string[]; - format?: 'openai'; + instructions?: AdHocInstruction[]; + format?: 'openai' | 'default'; } type ConversationSimulatorCallback = ( @@ -55,7 +60,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { ) => Promise; async function getResponseBody( - { actions, instructions, format }: RequestOptions, + { actions, instructions, format = 'default' }: RequestOptions, conversationSimulatorCallback: ConversationSimulatorCallback ) { const titleInterceptor = proxy.intercept('title', (body) => isFunctionTitleRequest(body)); @@ -65,30 +70,18 @@ export default function ApiTest({ getService }: FtrProviderContext) { (body) => !isFunctionTitleRequest(body) ); - const responsePromise = new Promise((resolve, reject) => { - supertest - .post(PUBLIC_COMPLETE_API_URL) - .query({ - format, - }) - .set('kbn-xsrf', 'foo') - .set('elastic-api-version', '2023-10-31') - .send({ + const responsePromise = observabilityAIAssistantAPIClient.adminUser({ + endpoint: 'POST /api/observability_ai_assistant/chat/complete 2023-10-31', + params: { + query: { format }, + body: { messages, connectorId, persist: true, actions, instructions, - }) - .end((err, response) => { - if (err) { - return reject(err); - } - if (response.status !== 200) { - return reject(new Error(`${response.status}: ${JSON.stringify(response.body)}`)); - } - return resolve(response); - }); + }, + }, }); const [conversationSimulator, titleSimulator] = await Promise.race([ @@ -141,32 +134,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { before(async () => { proxy = await createLlmProxy(log); - - const response = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: 'OpenAI Proxy', - connector_type_id: '.gen-ai', - config: { - apiProvider: 'OpenAI', - apiUrl: `http://localhost:${proxy.getPort()}`, - }, - secrets: { - apiKey: 'my-api-key', - }, - }) - .expect(200); - - connectorId = response.body.id; + connectorId = await createProxyActionConnector({ supertest, log, port: proxy.getPort() }); }); after(async () => { - await supertest - .delete(`/api/actions/connector/${connectorId}`) - .set('kbn-xsrf', 'foo') - .expect(204); - + await deleteActionConnector({ supertest, connectorId, log }); proxy.close(); }); @@ -225,12 +197,17 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); describe('after adding an instruction', async () => { - let body: string; + let body: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming; before(async () => { await getEvents( { - instructions: ['This is a random instruction'], + instructions: [ + { + text: 'This is a random instruction', + instruction_type: 'user_instruction', + }, + ], }, async (conversationSimulator) => { body = conversationSimulator.body; @@ -244,9 +221,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); it('includes the instruction in the system message', async () => { - const request = JSON.parse(body) as OpenAI.ChatCompletionCreateParams; - - expect(request.messages[0].content).to.contain('This is a random instruction'); + expect(body.messages[0].content).to.contain('This is a random instruction'); }); }); @@ -317,8 +292,3 @@ export default function ApiTest({ getService }: FtrProviderContext) { }); }); } - -function isFunctionTitleRequest(body: string) { - const parsedBody = JSON.parse(body) as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming; - return parsedBody.tools?.find((fn) => fn.function.name === 'title_conversation') !== undefined; -} diff --git a/x-pack/test/observability_ai_assistant_functional/tests/contextual_insights/index.spec.ts b/x-pack/test/observability_ai_assistant_functional/tests/contextual_insights/index.spec.ts index 7355b508ce5a0..b49c9fca76cd3 100644 --- a/x-pack/test/observability_ai_assistant_functional/tests/contextual_insights/index.spec.ts +++ b/x-pack/test/observability_ai_assistant_functional/tests/contextual_insights/index.spec.ts @@ -8,7 +8,6 @@ import { apm, timerange } from '@kbn/apm-synthtrace-client'; import expect from '@kbn/expect'; import moment from 'moment'; -import OpenAI from 'openai'; import { createLlmProxy, LlmProxy, @@ -123,11 +122,9 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte it('should show the contextual insight component on the APM error details page', async () => { await navigateToError(); - const interceptor = proxy.intercept( - 'conversation', - (body) => !isFunctionTitleRequest(body), - 'This error is nothing to worry about. Have a nice day!' - ); + const interceptor = proxy.interceptConversation({ + response: 'This error is nothing to worry about. Have a nice day!', + }); await openContextualInsights(); @@ -141,8 +138,3 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte }); }); } - -function isFunctionTitleRequest(body: string) { - const parsedBody = JSON.parse(body) as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming; - return parsedBody.functions?.find((fn) => fn.name === 'title_conversation') !== undefined; -} 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 3e766877c5bca..ff20297efdeca 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 @@ -9,9 +9,9 @@ import expect from '@kbn/expect'; import { MessageRole } from '@kbn/observability-ai-assistant-plugin/common'; import { ChatFeedback } from '@kbn/observability-ai-assistant-plugin/public/analytics/schemas/chat_feedback'; import { pick } from 'lodash'; -import type OpenAI from 'openai'; import { createLlmProxy, + isFunctionTitleRequest, LlmProxy, } from '../../../observability_ai_assistant_api_integration/common/create_llm_proxy'; import { interceptRequest } from '../../common/intercept_request'; @@ -227,20 +227,15 @@ export default function ApiTest({ getService, getPageObjects }: FtrProviderConte describe('and sending over some text', () => { before(async () => { - const titleInterceptor = proxy.intercept( - 'title', - (body) => - ( - JSON.parse(body) as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming - ).tools?.find((fn) => fn.function.name === 'title_conversation') !== undefined + const titleInterceptor = proxy.intercept('title', (body) => + isFunctionTitleRequest(body) ); const conversationInterceptor = proxy.intercept( 'conversation', (body) => - ( - JSON.parse(body) as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming - ).tools?.find((fn) => fn.function.name === 'title_conversation') === undefined + body.tools?.find((fn) => fn.function.name === 'title_conversation') === + undefined ); await testSubjects.setValue(ui.pages.conversations.chatInput, 'hello');