UNPKG

@microsoft/botbuilder-m365

Version:

M365 extensions for Microsoft BotBuilder, Alpha release.

404 lines (359 loc) 16.1 kB
/** * @module botbuilder-m365 */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { CardFactory, Channels, MessageFactory, TurnContext } from 'botbuilder'; import { ConversationHistory } from './ConversationHistory'; import { DefaultModerator } from './DefaultModerator'; import { DefaultTempState, DefaultTurnState } from './DefaultTurnStateManager'; import { Moderator } from './Moderator'; import { PredictedDoCommand, PredictedSayCommand, Planner, Plan } from './Planner'; import { PromptManager, PromptTemplate } from './Prompts'; import { ResponseParser } from './ResponseParser'; import { TurnState } from './TurnState'; export type PromptSelector<TState extends TurnState> = ( Context: TurnContext, state: TState ) => Promise<string | PromptTemplate>; export interface PredictedDoCommandAndHandler<TState> extends PredictedDoCommand { handler: (context: TurnContext, state: TState, data?: Record<string, any>, action?: string) => Promise<boolean>; } export interface AIOptions<TState extends TurnState> { planner: Planner<TState>; promptManager: PromptManager<TState>; moderator?: Moderator<TState>; prompt?: string | PromptTemplate | PromptSelector<TState>; history?: Partial<AIHistoryOptions>; } export interface AIHistoryOptions { trackHistory: boolean; maxTurns: number; maxTokens: number; lineSeparator: string; userPrefix: string; assistantPrefix: string; assistantHistoryType: 'text' | 'planObject'; } export interface ConfiguredAIOptions<TState extends TurnState> { planner: Planner<TState>; promptManager: PromptManager<TState>; moderator: Moderator<TState>; prompt?: string | PromptTemplate | ((Context: TurnContext, state: TState) => Promise<string | PromptTemplate>); history: AIHistoryOptions; } export class AI<TState extends TurnState = DefaultTurnState> { private readonly _actions: Map<string, ActionEntry<TState>> = new Map(); private readonly _options: ConfiguredAIOptions<TState>; public static readonly UnknownActionName = '___UnknownAction___'; public static readonly FlaggedInputActionName = '___FlaggedInput___'; public static readonly FlaggedOutputActionName = '___FlaggedOutput___'; public static readonly RateLimitedActionName = '___RateLimited___'; public static readonly PlanReadyActionName = '___PlanReady___'; public static readonly DoCommandActionName = '___DO___'; public static readonly SayCommandActionName = '___SAY___'; public constructor(options: AIOptions<TState>) { this._options = Object.assign({}, options) as ConfiguredAIOptions<TState>; // Create moderator if needed if (!this._options.moderator) { this._options.moderator = new DefaultModerator<TState>(); } // Initialize history options this._options.history = Object.assign( { trackHistory: true, maxTurns: 3, maxTokens: 1000, lineSeparator: '\n', userPrefix: 'User:', assistantPrefix: 'Assistant:', assistantHistoryType: 'planObject' } as AIHistoryOptions, this._options.history ); // Register default UnknownAction handler this.action( AI.UnknownActionName, (context, state, data, action?) => { console.error(`An AI action named "${action}" was predicted but no handler was registered.`); return Promise.resolve(true); }, true ); // Register default FlaggedInputAction handler this.action( AI.FlaggedInputActionName, (context, state, data, action) => { console.error( `The users input has been moderated but no handler was registered for 'AI.FlaggedInputActionName'.` ); return Promise.resolve(true); }, true ); // Register default FlaggedOutputAction handler this.action( AI.FlaggedOutputActionName, (context, state, data, action) => { console.error( `The bots output has been moderated but no handler was registered for 'AI.FlaggedOutputActionName'.` ); return Promise.resolve(true); }, true ); // Register default RateLimitedActionName this.action( AI.RateLimitedActionName, (context, state, data, action) => { throw new Error(`An AI request failed because it was rate limited`); }, true ); // Register default PlanReadyActionName this.action<Plan>( AI.PlanReadyActionName, async (context, state, plan) => { return Array.isArray(plan.commands) && plan.commands.length > 0; }, true ); // Register default DoCommandActionName this.action<PredictedDoCommandAndHandler<TState>>( AI.DoCommandActionName, async (context, state, data, action) => { const { entities, handler } = data; return await handler(context, state, entities, action); }, true ); // Register default SayCommandActionName this.action<PredictedSayCommand>( AI.SayCommandActionName, async (context, state, data, action) => { const response = data.response; const card = ResponseParser.parseAdaptiveCard(response); if (card) { const attachment = CardFactory.adaptiveCard(card); const activity = MessageFactory.attachment(attachment); await context.sendActivity(activity); } else if (context.activity.channelId == Channels.Msteams) { await context.sendActivity(response.split('\n').join('<br>')); } else { await context.sendActivity(response); } return true; }, true ); } public get moderator(): Moderator<TState> { return this._options.moderator; } public get options(): ConfiguredAIOptions<TState> { return this._options; } public get planner(): Planner<TState> { return this._options.planner; } public get prompts(): PromptManager<TState> { return this._options.promptManager; } /** * Registers a handler for a named action. * * * Actions can be triggered by a planner returning a DO command. * * @param {string | string[]} name Unique name of the action. * @param {Promise<boolean>} handler Function to call when the action is triggered. * @param {boolean} allowOverrides Optional. If true, default and/or existing handlers can be overridden. * @returns {this} The application instance for chaining purposes. */ public action<TEntities extends Record<string, any> | undefined>( name: string | string[], handler: (context: TurnContext, state: TState, entities: TEntities, action?: string) => Promise<boolean>, allowOverrides = false ): this { (Array.isArray(name) ? name : [name]).forEach((n) => { if (!this._actions.has(n) || allowOverrides) { this._actions.set(n, { handler, allowOverrides }); } else { const entry = this._actions.get(n); if (entry!.allowOverrides) { entry!.handler = handler; } else { throw new Error( `The AI.action() method was called with a previously registered action named "${n}".` ); } } }); return this; } public async chain( context: TurnContext, state: TState, prompt?: string | PromptTemplate, options?: Partial<AIOptions<TState>> ): Promise<boolean> { // Configure options const opts = this.configureOptions(options); // Select prompt if (!prompt) { if (opts.prompt == undefined) { throw new Error(`AI.chain() was called without a prompt and no default prompt was configured.`); } else if (typeof opts.prompt == 'function') { prompt = await opts.prompt(context, state); } else { prompt = opts.prompt; } } // Populate {{$temp.input}} const temp = (state as any as DefaultTurnState)?.temp?.value ?? ({} as DefaultTempState); if (typeof temp.input != 'string') { // Use the received activity text temp.input = context.activity.text; } // Populate {{$temp.history}} if (typeof temp.history != 'string' && opts.history.trackHistory) { temp.history = ConversationHistory.toString(state, opts.history.maxTokens, opts.history.lineSeparator); } // Render the prompt const renderedPrompt = await opts.promptManager.renderPrompt(context, state, prompt); // Generate plan let plan = await opts.moderator.reviewPrompt(context, state, renderedPrompt, opts); if (!plan) { plan = await opts.planner.generatePlan(context, state, renderedPrompt, opts); plan = await opts.moderator.reviewPlan(context, state, plan); } // Process generated plan let continueChain = await this._actions.get(AI.PlanReadyActionName)!.handler(context, state, plan, ''); if (continueChain) { // Update conversation history if (opts.history.trackHistory) { ConversationHistory.addLine( state, `${opts.history.userPrefix.trim()} ${temp.input.trim()}`, opts.history.maxTurns * 2 ); switch (opts.history.assistantHistoryType) { case 'text': { // Extract only the things the assistant has said const text = plan.commands .filter((v) => v.type == 'SAY') .map((v) => (v as PredictedSayCommand).response) .join('\n'); ConversationHistory.addLine( state, `${opts.history.assistantPrefix.trim()} ${text}`, opts.history.maxTurns * 2 ); break; } case 'planObject': default: // Embed the plan object to re-enforce the model // - TODO: add support for XML as well ConversationHistory.addLine( state, `${opts.history.assistantPrefix.trim()} ${JSON.stringify(plan)}`, opts.history.maxTurns * 2 ); break; } } // Run predicted commands for (let i = 0; i < plan.commands.length && continueChain; i++) { // TODO // eslint-disable-next-line security/detect-object-injection const cmd = plan.commands[i]; switch (cmd.type) { case 'DO': { const { action } = cmd as PredictedDoCommand; if (this._actions.has(action)) { // Call action handler const handler = this._actions.get(action)!.handler; continueChain = await this._actions .get(AI.DoCommandActionName)! .handler(context, state, { handler, ...(cmd as PredictedDoCommand) }, action); } else { // Redirect to UnknownAction handler continueChain = await this._actions .get(AI.UnknownActionName)! .handler(context, state, plan, action); } break; } case 'SAY': continueChain = await this._actions .get(AI.SayCommandActionName)! .handler(context, state, cmd, AI.SayCommandActionName); break; default: throw new Error(`Application.run(): unknown command of '${cmd.type}' predicted.`); } } } return continueChain; } public async completePrompt( context: TurnContext, state: TState, prompt: string | PromptTemplate, options?: Partial<AIOptions<TState>> ): Promise<string | undefined> { // Configure options const opts = this.configureOptions(options); // Render the prompt const renderedPrompt = await opts.promptManager.renderPrompt(context, state, prompt); // Complete the prompt return await opts.planner.completePrompt(context, state, renderedPrompt, opts); } public createSemanticFunction( name: string, template?: PromptTemplate, options?: Partial<AIOptions<TState>> ): (context: TurnContext, state: TState) => Promise<any> { // Cache prompt template if being dynamically assigned if (template) { this._options.promptManager.addPromptTemplate(name, template); } return (context: TurnContext, state: TState) => this.completePrompt(context, state, name, options); } public doAction<TData = Record<string, any>>( context: TurnContext, state: TState, action: string, data?: TData ): Promise<boolean> { if (!this._actions.has(action)) { throw new Error(`Can't find an action named '${action}'.`); } const handler = this._actions.get(action)!.handler; return handler(context, state, data, action); } private configureOptions(options?: Partial<AIOptions<TState>>): ConfiguredAIOptions<TState> { let configuredOptions: ConfiguredAIOptions<TState>; if (options) { configuredOptions = Object.assign({}, this._options, options) as ConfiguredAIOptions<TState>; if (options.history) { // Just inherit any missing history settings options.history = Object.assign({}, this._options.history, options.history); } else { // Disable history tracking by default options.history = Object.assign({}, this._options.history, { trackHistory: false }); } } else { configuredOptions = this._options; } return configuredOptions; } } /* TData in a handler is of type Record<string, any>, specified by the developer. However, since by default TData is not set, we use type `any` here. */ interface ActionEntry<TState> { handler: (context: TurnContext, state: TState, data?: any, action?: string) => Promise<boolean>; allowOverrides: boolean; }