dotprompt
Version:
Dotprompt: Executable GenAI Prompt Templates
534 lines (476 loc) • 16.6 kB
text/typescript
/**
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
import Handlebars from 'handlebars/dist/cjs/handlebars.js';
import * as builtinHelpers from './helpers';
import { parseDocument, toMessages } from './parse';
import { picoschema } from './picoschema';
import type {
DataArgument,
JSONSchema,
ParsedPrompt,
PromptFunction,
PromptMetadata,
PromptStore,
RenderedPrompt,
Schema,
SchemaResolver,
ToolDefinition,
ToolResolver,
} from './types';
import { removeUndefinedFields } from './util';
/** Function to resolve partial names to their content */
export type PartialResolver = (
partialName: string
) => string | null | Promise<string | null>;
/** Options for the Dotprompt class. */
export interface DotpromptOptions {
/** A default model to use if none is supplied. */
defaultModel?: string;
/** Assign a set of default configuration options to be used with a particular model. */
modelConfigs?: Record<string, object>;
/** Helpers to pre-register. */
helpers?: Record<string, Handlebars.HelperDelegate>;
/** Partials to pre-register. */
partials?: Record<string, string>;
/** Provide a static mapping of tool definitions that should be used when resolving tool names. */
tools?: Record<string, ToolDefinition>;
/** Provide a lookup implementation to resolve tool names to definitions. */
toolResolver?: ToolResolver;
/** Provide a static mapping of schema names to their JSON Schema definitions. */
schemas?: Record<string, JSONSchema>;
/** Provide a lookup implementation to resolve schema names to JSON schema definitions. */
schemaResolver?: SchemaResolver;
/** Provide a lookup implementation to resolve partial names to their content. */
partialResolver?: PartialResolver;
}
/**
* The main class for the Dotprompt library.
*/
export class Dotprompt {
private handlebars: typeof Handlebars;
private knownHelpers: Record<string, true> = {};
private defaultModel?: string;
private modelConfigs: Record<string, object> = {};
private tools: Record<string, ToolDefinition> = {};
private toolResolver?: ToolResolver;
private schemas: Record<string, JSONSchema> = {};
private schemaResolver?: SchemaResolver;
private partialResolver?: PartialResolver;
private store?: PromptStore;
constructor(options?: DotpromptOptions) {
this.handlebars = Handlebars.noConflict();
this.modelConfigs = options?.modelConfigs || this.modelConfigs;
this.defaultModel = options?.defaultModel;
this.tools = options?.tools || {};
this.toolResolver = options?.toolResolver;
this.schemas = options?.schemas || {};
this.schemaResolver = options?.schemaResolver;
this.partialResolver = options?.partialResolver;
this.registerInitialHelpers(builtinHelpers, options?.helpers);
this.registerInitialPartials(options?.partials);
}
/**
* Registers a helper function for use in templates.
*
* @param name The name of the helper function to register
* @param fn The helper function implementation
* @return This instance for method chaining
*/
defineHelper(name: string, fn: Handlebars.HelperDelegate): this {
this.handlebars.registerHelper(name, fn);
this.knownHelpers[name] = true;
return this;
}
/**
* Registers a partial template for use in other templates.
*
* @param name The name of the partial to register
* @param source The template source for the partial
* @return This instance for method chaining
*/
definePartial(name: string, source: string): this {
this.handlebars.registerPartial(name, source);
return this;
}
/**
* Registers a tool definition for use in prompts.
*
* @param def The tool definition to register
* @return This instance for method chaining
*/
defineTool(def: ToolDefinition): this {
this.tools[def.name] = def;
return this;
}
/**
* Parses a prompt template string into a structured ParsedPrompt object.
*
* @param source The template source string to parse
* @return A parsed prompt object with extracted metadata and template
*/
parse<ModelConfig = Record<string, unknown>>(
source: string
): ParsedPrompt<ModelConfig> {
return parseDocument<ModelConfig>(source);
}
/**
* Renders a prompt template with the provided data.
*
* @param source The template source string to render
* @param data The data to use when rendering the template
* @param options Additional metadata and options for rendering
* @return A promise resolving to the rendered prompt
*/
async render<
Variables = Record<string, unknown>,
ModelConfig = Record<string, unknown>,
>(
source: string,
data: DataArgument<Variables> = {},
options?: PromptMetadata<ModelConfig>
): Promise<RenderedPrompt<ModelConfig>> {
const renderer = await this.compile<Variables, ModelConfig>(source);
return renderer(data, options);
}
/**
* Compiles a template into a reusable function for rendering prompts.
*
* @param source The template source or parsed prompt to compile
* @param additionalMetadata Additional metadata to include in the compiled template
* @return A promise resolving to a function for rendering the template
*/
async compile<
Variables = Record<string, unknown>,
ModelConfig = Record<string, unknown>,
>(
source: string | ParsedPrompt<ModelConfig>,
additionalMetadata?: PromptMetadata<ModelConfig>
): Promise<PromptFunction<ModelConfig>> {
let parsedSource: ParsedPrompt<ModelConfig>;
if (typeof source === 'string') {
parsedSource = this.parse<ModelConfig>(source);
} else {
parsedSource = source;
}
if (additionalMetadata) {
parsedSource = { ...parsedSource, ...additionalMetadata };
}
// Resolve all partials before compilation.
await this.resolvePartials(parsedSource.template);
const renderString = this.handlebars.compile<Variables>(
parsedSource.template,
{
knownHelpers: this.knownHelpers,
knownHelpersOnly: true,
noEscape: true,
}
);
// Create an instance of a PromptFunction.
const renderFunc = async (
data: DataArgument,
options?: PromptMetadata<ModelConfig>
) => {
// Discard the input schema as once rendered it doesn't make sense.
const { input, ...mergedMetadata } =
await this.renderMetadata(parsedSource);
const renderedString = renderString(
{ ...(options?.input?.default || {}), ...data.input },
{
data: {
metadata: {
prompt: mergedMetadata,
docs: data.docs,
messages: data.messages,
},
...(data.context || {}),
},
}
);
return {
...mergedMetadata,
messages: toMessages<ModelConfig>(renderedString, data),
};
};
// Add the parsed source to the prompt function as a property.
(renderFunc as PromptFunction<ModelConfig>).prompt = parsedSource;
return renderFunc as PromptFunction<ModelConfig>;
}
/**
* Processes and resolves all metadata for a prompt template.
*
* @param source The template source or parsed prompt
* @param additionalMetadata Additional metadata to include
* @return A promise resolving to the fully processed metadata
*/
async renderMetadata<ModelConfig>(
source: string | ParsedPrompt<ModelConfig>,
additionalMetadata?: PromptMetadata<ModelConfig>
): Promise<PromptMetadata<ModelConfig>> {
let parsedSource: ParsedPrompt<ModelConfig>;
if (typeof source === 'string') {
parsedSource = this.parse<ModelConfig>(source);
} else {
parsedSource = source;
}
const model =
additionalMetadata?.model || parsedSource.model || this.defaultModel;
let modelConfig: ModelConfig | undefined;
if (model && this.modelConfigs[model]) {
modelConfig = this.modelConfigs[model] as ModelConfig;
}
return this.resolveMetadata<ModelConfig>(
modelConfig ? { config: modelConfig } : {},
parsedSource,
additionalMetadata
);
}
/**
* Merges multiple metadata objects together, resolving tools and schemas.
*
* @param base The base metadata object
* @param merges Additional metadata objects to merge into the base
* @return A promise resolving to the merged and processed metadata
*/
private async resolveMetadata<ModelConfig = Record<string, unknown>>(
base: PromptMetadata<ModelConfig>,
...merges: (PromptMetadata<ModelConfig> | undefined)[]
): Promise<PromptMetadata<ModelConfig>> {
let out = { ...base };
for (let i = 0; i < merges.length; i++) {
if (!merges[i]) {
continue;
}
// Keep a reference to the original config.
const originalConfig = out.config || ({} as ModelConfig);
// Merge the new metadata.
out = { ...out, ...merges[i] };
// Merge the configs.
out.config = { ...originalConfig, ...(merges[i]?.config || {}) };
}
// Remove the template attribute if it exists.
const { template: _, ...outWithoutTemplate } =
out as PromptMetadata<ModelConfig> & { template?: string };
out = outWithoutTemplate as PromptMetadata<ModelConfig>;
out = removeUndefinedFields(out);
// TODO: Can this be done concurrently?
out = await this.resolveTools(out);
out = await this.renderPicoschema(out);
return out;
}
/**
* Processes schema definitions in picoschema format into standard JSON Schema.
*
* @param meta The prompt metadata containing schema definitions
* @return A promise resolving to the processed metadata with expanded schemas
*/
private async renderPicoschema<ModelConfig>(
meta: PromptMetadata<ModelConfig>
): Promise<PromptMetadata<ModelConfig>> {
if (!meta.output?.schema && !meta.input?.schema) {
return meta;
}
const resolveSchema = (schema: Schema): Promise<Schema> => {
return picoschema(schema, {
schemaResolver: this.wrappedSchemaResolver.bind(this),
});
};
const newMeta = { ...meta };
let inputPromise: Promise<Schema | null> | null = null;
let outputPromise: Promise<Schema | null> | null = null;
// Collect all schemas to resolve.
if (meta.input?.schema) {
newMeta.input = { ...meta.input };
inputPromise = resolveSchema(meta.input.schema);
}
if (meta.output?.schema) {
newMeta.output = { ...meta.output };
outputPromise = resolveSchema(meta.output.schema);
}
// Resolve schemas concurrently.
const [inputSchema, outputSchema] = await Promise.all([
inputPromise ?? Promise.resolve(null),
outputPromise ?? Promise.resolve(null),
]);
if (inputSchema && newMeta.input) {
newMeta.input.schema = inputSchema;
}
if (outputSchema && newMeta.output) {
newMeta.output.schema = outputSchema;
}
return newMeta;
}
/**
* Resolves a schema name to its definition, using registered schemas or schema resolver.
*
* @param name The name of the schema to resolve
* @return A promise resolving to the schema definition or null if not found
*/
private async wrappedSchemaResolver(
name: string
): Promise<JSONSchema | null> {
if (this.schemas[name]) {
return this.schemas[name];
}
if (this.schemaResolver) {
return await this.schemaResolver(name);
}
return null;
}
/**
* Resolves tool names to their definitions using registered tools or tool resolver.
*
* @param base The metadata containing tool references to resolve
* @return A promise resolving to metadata with resolved tool definitions
*/
private async resolveTools<ModelConfig>(
base: PromptMetadata<ModelConfig>
): Promise<PromptMetadata<ModelConfig>> {
const out = { ...base };
if (!out.tools) {
return out;
}
// Resolve tools that are already registered into toolDefs, leave
// unregistered tools alone.
const unregisteredNames: string[] = [];
out.toolDefs = out.toolDefs || [];
await Promise.all(
out.tools.map(async (name: string) => {
if (this.tools[name]) {
// Found locally.
if (out.toolDefs) {
out.toolDefs.push(this.tools[name]);
}
} else if (this.toolResolver) {
// Resolve from the tool resolver.
const resolvedTool = await this.toolResolver(name);
if (!resolvedTool) {
throw new Error(
`Dotprompt: Unable to resolve tool '${name}' to a recognized tool definition.`
);
}
if (out.toolDefs) {
out.toolDefs.push(resolvedTool);
}
} else {
// Unregistered tool.
unregisteredNames.push(name);
}
})
);
out.tools = unregisteredNames;
return out;
}
/**
* Identifies all partial references in a template.
*
* @param template The template to scan for partial references
* @return A set of partial names referenced in the template
*/
private identifyPartials(template: string): Set<string> {
const ast = this.handlebars.parse(template);
const partials = new Set<string>();
// Create a visitor to collect partial names.
const visitor = new (class extends this.handlebars.Visitor {
// Visit partial statements and add their names to our set.
PartialStatement(partial: unknown): void {
if (
partial &&
typeof partial === 'object' &&
'name' in partial &&
partial.name &&
typeof partial.name === 'object' &&
'original' in partial.name &&
typeof partial.name.original === 'string'
) {
partials.add(partial.name.original);
}
}
})();
visitor.accept(ast);
return partials;
}
/**
* Resolves and registers all partials referenced in a template.
*
* @param template The template containing partial references
* @return A promise that resolves when all partials are registered
*/
private async resolvePartials(template: string): Promise<void> {
if (!this.partialResolver && !this.store) {
return;
}
const names = this.identifyPartials(template);
// Resolve and register each partial.
await Promise.all(
Array.from(names).map(async (name: string) => {
if (!this.handlebars.partials[name]) {
let content: string | null | undefined = null;
if (this.partialResolver) {
content = await this.partialResolver(name);
}
if (!content && this.store) {
const partial = await this.store.loadPartial(name);
content = partial?.source;
}
if (content) {
this.definePartial(name, content);
// Recursively resolve partials in the partial content.
await this.resolvePartials(content);
}
}
})
);
}
/**
* Registers initial helpers from built-in helpers and options.
* @private
*/
private registerInitialHelpers(
builtinHelpers?: Record<string, Handlebars.HelperDelegate>,
customHelpers?: Record<string, Handlebars.HelperDelegate>
): void {
// Register built-in helpers
if (builtinHelpers) {
for (const key in builtinHelpers) {
this.defineHelper(
key,
builtinHelpers[key as keyof typeof builtinHelpers]
);
}
}
// Register custom helpers from options
if (customHelpers) {
for (const key in customHelpers) {
this.defineHelper(key, customHelpers[key]);
}
}
}
/**
* Registers initial partials from the options.
*
* @param partials The partials to register
* @private
*/
private registerInitialPartials(partials?: Record<string, string>): void {
if (partials) {
for (const key in partials) {
this.definePartial(key, partials[key]);
}
}
}
}