[Obs AI Assistant] Add KB user instructions (#187607)

Related to: https://github.com/elastic/obs-ai-assistant-team/issues/157

This adds a top-level property `type: 'user_instruction' | 'contextual'`
to the knowledge base.

The description given to the LLM:

> The type can be: "user_instruction" or "contextual". A
"user_instruction" entry will be included in the system message if it
fits within the token budget and it will be marked as a user
instruction. A "contextual" entry will be included as part of the
context function response (and not the system message) if it
semantically matches the user's prompt.


# Screenshots

**"Edit system prompt" button**

![image](36cd8571-2c21-4b69-8c17-98a0d53e9452)


**"Edit system prompt" flyout**

![image](ed802390-71d8-448d-964c-04afdbecc0ae)




cc781d46-3966-4530-8036-dd44506ec771
This commit is contained in:
Søren Louv-Jansen 2024-08-20 01:30:03 +02:00 committed by GitHub
parent 3ff9f920cc
commit e9f23aa98e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1244 additions and 525 deletions

View file

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

View file

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

View file

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

View file

@ -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<StreamingChatResponseEventWithoutError>;
getFunctions: (options?: { contexts?: string[]; filter?: string }) => FunctionDefinition[];
hasFunction: (name: string) => boolean;

View file

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

View file

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

View file

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

View file

@ -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<Instruction & { public?: boolean }>;
}> => {
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<void> => {
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,

View file

@ -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<string, ValidateFunction> = new Map();
@ -107,7 +107,7 @@ export class ChatFunctionClient {
}
}
getInstructions(): RegisteredInstruction[] {
getInstructions(): InstructionOrCallback[] {
return this.instructions;
}

View file

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

View file

@ -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<KnowledgeBaseEntry, '@timestamp'>;
@ -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

View file

@ -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<string | UserInstruction>;
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,

View file

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

View file

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

View file

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

View file

@ -38,6 +38,7 @@ export const kbComponentTemplate: ClusterComponentTemplate['component_template']
name: keyword,
},
},
type: keyword,
labels: dynamic,
conversation: {
properties: {

View file

@ -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<UserInstruction[]> => {
): Promise<Array<Instruction & { public?: boolean }>> => {
try {
const response = await this.dependencies.esClient.asInternalUser.search<KnowledgeBaseEntry>({
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<KnowledgeBaseEntry>({
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<KnowledgeBaseEntry, 'doc_id'>
>({
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<void> => {
// 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,

View file

@ -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<any, FunctionResponse>;
}
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<UserInstructionOrPlainText | RegisterInstructionCallback>
) => void;
export type RegisterInstruction = (...instructions: InstructionOrCallback[]) => void;
export type RegisterFunction = <
TParameters extends CompatibleJSONSchema = any,

View file

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

View file

@ -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(),
}));
// split ad hoc instructions into user instructions and application instructions
const [adHocUserInstructions, adHocApplicationInstructions] = partition(
adHocInstructionsWithId,
(instruction) => instruction.instruction_type === 'user_instruction'
);
const [requestSystemInstructions, requestUserInstructionsWithId] = partition(
requestInstructionsWithId,
(instruction) => instruction.system === true
// 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 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))
);
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;

View file

@ -20,11 +20,10 @@
"uiActions",
"triggersActionsUi",
"share",
"security",
"licensing",
"ml",
"alerting",
"features",
"features"
],
"requiredBundles": ["kibanaReact", "esqlDataGrid"],
"optionalPlugins": ["cloud"],

View file

@ -11,11 +11,7 @@ import { useKibana } from './use_kibana';
export function useCurrentUser() {
const {
services: {
plugins: {
start: { security },
},
},
services: { security },
} = useKibana();
const [user, setUser] = useState<AuthenticatedUser>();

View file

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

View file

@ -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: [],
}),
},
},

View file

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

View file

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

View file

@ -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<ResponseErrorBody>;
export function useCreateKnowledgeBaseUserInstruction() {
const {
observabilityAIAssistant,
notifications: { toasts },
} = useKibana().services;
const queryClient = useQueryClient();
const observabilityAIAssistantApi = observabilityAIAssistant.service.callApi;
return useMutation<void, ServerError, { entry: Instruction & { public: boolean } }>(
[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 },
}
),
});
},
}
);
}

View file

@ -20,16 +20,12 @@ export function useDeleteKnowledgeBaseEntry() {
} = useKibana().services;
const queryClient = useQueryClient();
const observabilityAIAssistantApi = observabilityAIAssistant?.service.callApi;
const observabilityAIAssistantApi = observabilityAIAssistant.service.callApi;
return useMutation<unknown, ServerError, { id: string }>(
[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,

View file

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

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
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,
};
}

View file

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

View file

@ -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<string>();
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 (
<EuiFlyout onClose={onClose}>
<EuiFlyoutHeader hasBorder data-test-subj="knowledgeBaseManualEntryFlyout">
<EuiTitle>
<h2>
{i18n.translate(
'xpack.observabilityAiAssistantManagement.knowledgeBaseEditSystemPrompt.h2.editEntryLabel',
{ defaultMessage: 'AI User Profile' }
)}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiText>
{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.',
}
)}
</EuiText>
<EuiSpacer size="s" />
<EuiFormRow fullWidth>
<EuiMarkdownEditor
editorId="knowledgeBaseEditManualEntryFlyoutMarkdownEditor"
aria-label={i18n.translate(
'xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.euiMarkdownEditor.observabilityAiAssistantKnowledgeBaseViewMarkdownEditorLabel',
{ defaultMessage: 'observabilityAiAssistantKnowledgeBaseViewMarkdownEditor' }
)}
height={300}
initialViewMode="editing"
readOnly={isFetching}
placeholder={i18n.translate(
'xpack.observabilityAiAssistantManagement.knowledgeBaseEditManualEntryFlyout.euiMarkdownEditor.enterContentsLabel',
{ defaultMessage: 'Enter contents' }
)}
value={newEntryText}
onChange={(text) => setNewEntryText(text)}
/>
</EuiFormRow>
<EuiSpacer size="m" />
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="knowledgeBaseEditManualEntryFlyoutCancelButton"
disabled={isSaving}
onClick={onClose}
>
{i18n.translate(
'xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.cancelButtonEmptyLabel',
{ defaultMessage: 'Cancel' }
)}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="knowledgeBaseEditManualEntryFlyoutSaveButton"
fill
isLoading={isSaving}
onClick={handleSubmit}
isDisabled={isSubmitDisabled}
>
{i18n.translate(
'xpack.observabilityAiAssistantManagement.knowledgeBaseNewManualEntryFlyout.saveButtonLabel',
{ defaultMessage: 'Save' }
)}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
}

View file

@ -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<HTMLInputElement> | undefined) => {
setQuery(e?.currentTarget.value || '');
};
@ -236,6 +234,7 @@ export function KnowledgeBaseTab() {
)}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="knowledgeBaseTabReloadButton"
@ -249,17 +248,30 @@ export function KnowledgeBaseTab() {
)}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="observabilityAiAssistantManagementKnowledgeBaseTabEditInstructionsButton"
color="text"
onClick={() => setIsEditUserInstructionFlyoutOpen(true)}
>
{i18n.translate(
'xpack.observabilityAiAssistantManagement.knowledgeBaseTab.editInstructionsButtonLabel',
{ defaultMessage: 'Edit AI User Profile' }
)}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
isOpen={newEntryPopoverOpen}
closePopover={() => setNewEntryPopoverOpen(false)}
isOpen={isNewEntryPopoverOpen}
closePopover={() => setIsNewEntryPopoverOpen(false)}
button={
<EuiButton
fill
data-test-subj="knowledgeBaseNewEntryButton"
iconSide="right"
iconType="arrowDown"
onClick={handleClickNewEntry}
onClick={() => 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() {
</EuiFlexItem>
</EuiFlexGroup>
{flyoutOpenType === 'singleEntry' ? (
<KnowledgeBaseEditManualEntryFlyout onClose={() => setFlyoutOpenType(undefined)} />
{isEditUserInstructionFlyoutOpen ? (
<KnowledgeBaseEditUserInstructionFlyout
onClose={() => setIsEditUserInstructionFlyoutOpen(false)}
/>
) : null}
{flyoutOpenType === 'bulkImport' ? (
<KnowledgeBaseBulkImportFlyout onClose={() => setFlyoutOpenType(undefined)} />
{newEntryFlyoutType === 'singleEntry' ? (
<KnowledgeBaseEditManualEntryFlyout onClose={() => setNewEntryFlyoutType(undefined)} />
) : null}
{newEntryFlyoutType === 'bulkImport' ? (
<KnowledgeBaseBulkImportFlyout onClose={() => setNewEntryFlyoutType(undefined)} />
) : null}
{selectedCategory ? (

View file

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

View file

@ -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": "アクションが必要です。",

View file

@ -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": "“操作”必填。",

View file

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

View file

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

View file

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

View file

@ -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<http.IncomingMessage> & { 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<void>;
next: (
msg:
@ -40,10 +46,13 @@ export interface LlmResponseSimulator {
export class LlmProxy {
server: Server;
interval: NodeJS.Timeout;
interceptors: Array<RequestInterceptor & { handle: RequestHandler }> = [];
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<Record<string, unknown>> | 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<string> {
async function getRequestBody(
request: http.IncomingMessage
): Promise<OpenAI.Chat.ChatCompletionCreateParamsNonStreaming> {
return new Promise((resolve, reject) => {
let data = '';
@ -188,7 +224,7 @@ async function getRequestBody(request: http.IncomingMessage): Promise<string> {
});
request.on('close', () => {
resolve(data);
resolve(JSON.parse(data));
});
request.on('error', (error) => {
@ -196,3 +232,9 @@ async function getRequestBody(request: http.IncomingMessage): Promise<string> {
});
});
}
export function isFunctionTitleRequest(body: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming) {
return (
body.tools?.find((fn) => fn.function.name === TITLE_CONVERSATION_FUNCTION_NAME) !== undefined
);
}

View file

@ -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 <TEndpoint extends ObservabilityAIAssistantAPIEndpoint>(
options: {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<typeof MachineLear
await ml.api.importTrainedModel(TINY_ELSER.name, TINY_ELSER.id, config);
await ml.api.assureMlStatsIndexExists();
}
export async function deleteKnowledgeBaseModel(ml: ReturnType<typeof MachineLearningProvider>) {
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,
});
}

View file

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

View file

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

View file

@ -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<Pick<FunctionDefinition, 'name' | 'description' | 'parameters'>>;
instructions?: string[];
format?: 'openai';
instructions?: AdHocInstruction[];
format?: 'openai' | 'default';
}
type ConversationSimulatorCallback = (
@ -55,7 +60,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
) => Promise<void>;
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<Response>((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;
}

View file

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

View file

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