UNPKG

@microsoft/botbuilder-m365

Version:

M365 extensions for Microsoft BotBuilder, Alpha release.

186 lines (155 loc) 7.31 kB
/* eslint-disable security/detect-object-injection */ /** * @module botbuilder-m365 */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ import { TurnContext } from 'botbuilder'; import { PromptManager } from '../Prompts'; import { TurnState } from '../TurnState'; import { Block, BlockTypes } from './Block'; import { CodeBlock } from './CodeBlock'; import { TextBlock } from './TextBlock'; import { VarBlock } from './VarBlock'; /** * Given a prompt, that might contain references to variables and functions: * - Get the list of references * - Resolve each reference * - Variable references are resolved using the context variables * - Function references are resolved invoking those functions * - Functions can be invoked passing in variables * - Functions do not receive the context variables, unless specified using a special variable * - Functions can be invoked in order and in parallel so the context variables must be immutable when invoked within the template */ export class PromptTemplateEngine<TState extends TurnState> { private readonly _promptManager: PromptManager<TState>; public constructor(promptManager: PromptManager<TState>) { this._promptManager = promptManager; } public extractBlocks(templateText?: string, validate = true): Block[] { const blocks = this.tokenizeInternal(templateText); if (validate) { this.validateBlocksSyntax(blocks); } return blocks; } public async render(context: TurnContext, state: TState, textOrBlocks: string | Block[]): Promise<string> { if (typeof textOrBlocks == 'string') { const blocks = this.extractBlocks(textOrBlocks); return await this.render(context, state, blocks); } else { let result = ''; for (const block of textOrBlocks) { switch (block.type) { case BlockTypes.Text: result += block.content; break; case BlockTypes.Variable: result += block.render(context, state); break; case BlockTypes.Code: result += await block.renderCode(context, state, this._promptManager); break; case BlockTypes.Undefined: default: throw new Error(`Invalid bock of type "${block.type}" encountered.`); } } // TODO: good time to count tokens, though that depends on the model used return result; } } public renderVariables(context: TurnContext, state: TState, blocks: Block[]): Block[] { return blocks.map((block) => { return block.type != BlockTypes.Variable ? block : new TextBlock(block.render(context, state)); }); } public async renderCode(context: TurnContext, state: TState, blocks: Block[]): Promise<Block[]> { const updatedBlocks: Block[] = []; for (const block of blocks) { if (block.type != BlockTypes.Code) { updatedBlocks.push(block); } else { const codeResult = await block.renderCode(context, state, this._promptManager); updatedBlocks.push(new TextBlock(codeResult)); } } return updatedBlocks; } // Blocks delimitation private readonly starter: string = '{'; private readonly ender: string = '}'; private tokenizeInternal(template?: string): Block[] { // An empty block consists of 4 chars: "{{}}" const EMPTY_CODE_BLOCK_LENGTH = 4; // A block shorter than 5 chars is either empty or invalid, e.g. "{{ }}" and "{{$}}" const MIN_CODE_BLOCK_LENGTH = EMPTY_CODE_BLOCK_LENGTH + 1; // Render NULL to "" // Since the template is a string, all `template` instances can have non-null assertion if (template === null) { return [new TextBlock('')]; } // If the template is "empty" return the content as a text block if (template!.length < MIN_CODE_BLOCK_LENGTH) { return [new TextBlock(template!)]; } const blocks: Block[] = []; let cursor = 0; let endOfLastBlock = 0; let startPos = 0; let startFound = false; while (cursor < template!.length - 1) { // When "{{" is found if (template![cursor] === this.starter && template![cursor + 1] === this.starter) { startPos = cursor; startFound = true; } // When "}}" is found else if (startFound && template![cursor] === this.ender && template![cursor + 1] === this.ender) { // If there is plain text between the current var/code block and the previous one, capture that as a TextBlock if (startPos > endOfLastBlock) { blocks.push(new TextBlock(template!, endOfLastBlock, startPos)); } // Skip ahead to the second "}" of "}}" cursor++; // Extract raw block const contentWithDelimiters = template!.substring(startPos, cursor + 1); // Remove "{{" and "}}" delimiters and trim empty chars const contentWithoutDelimiters = contentWithDelimiters .substring(2, contentWithDelimiters.length - 2) .trim(); if (contentWithoutDelimiters.length === 0) { // If what is left is empty, consider the raw block a Text Block blocks.push(new TextBlock(contentWithDelimiters)); } else { // If the block starts with "$" it's a variable if (VarBlock.hasVarPrefix(contentWithoutDelimiters)) { // Note: validation is delayed to the time VarBlock is rendered blocks.push(new VarBlock(contentWithoutDelimiters)); } else { // Note: validation is delayed to the time CodeBlock is rendered blocks.push(new CodeBlock(contentWithoutDelimiters)); } } endOfLastBlock = cursor + 1; startFound = false; } cursor++; } // If there is something left after the last block, capture it as a TextBlock if (endOfLastBlock < template!.length) { blocks.push(new TextBlock(template!, endOfLastBlock, template!.length)); } return blocks; } private validateBlocksSyntax(blocks: Block[]): void { blocks.forEach((block) => { const { valid, errorMessage: error } = block.isValid(); if (!valid || error) { throw new Error(`Prompt template syntax error: ${error?.toString() ?? 'unknown'}`); } }); } }