@microsoft/botbuilder-m365
Version:
M365 extensions for Microsoft BotBuilder, Alpha release.
186 lines (155 loc) • 7.31 kB
text/typescript
/* 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'}`);
}
});
}
}