UNPKG

dotprompt

Version:

Dotprompt: Executable GenAI Prompt Templates

762 lines (755 loc) 23.2 kB
var __defProp = Object.defineProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; // src/dotprompt.ts import Handlebars from "handlebars/dist/cjs/handlebars.js"; // src/helpers.ts var helpers_exports = {}; __export(helpers_exports, { history: () => history, ifEquals: () => ifEquals, json: () => json, media: () => media, role: () => role, section: () => section, unlessEquals: () => unlessEquals }); import handlebars from "handlebars/dist/cjs/handlebars.js"; var { SafeString } = handlebars; function json(serializable, options) { return new SafeString( JSON.stringify(serializable, null, options.hash.indent || 0) ); } function role(role2) { return new SafeString(`<<<dotprompt:role:${role2}>>>`); } function history() { return new SafeString("<<<dotprompt:history>>>"); } function section(name) { return new SafeString(`<<<dotprompt:section ${name}>>>`); } function media(options) { return new SafeString( `<<<dotprompt:media:url ${options.hash.url}${options.hash.contentType ? ` ${options.hash.contentType}` : ""}>>>` ); } function ifEquals(arg1, arg2, options) { return arg1 === arg2 ? options.fn(this) : options.inverse(this); } function unlessEquals(arg1, arg2, options) { return arg1 !== arg2 ? options.fn(this) : options.inverse(this); } // src/parse.ts import { parse } from "yaml"; var ROLE_MARKER_PREFIX = "<<<dotprompt:role:"; var HISTORY_MARKER_PREFIX = "<<<dotprompt:history"; var MEDIA_MARKER_PREFIX = "<<<dotprompt:media:"; var SECTION_MARKER_PREFIX = "<<<dotprompt:section"; var FRONTMATTER_AND_BODY_REGEX = /^---\s*(?:\r\n|\r|\n)([\s\S]*?)(?:\r\n|\r|\n)---\s*(?:\r\n|\r|\n)([\s\S]*)$/; var ROLE_AND_HISTORY_MARKER_REGEX = /(<<<dotprompt:(?:role:[a-z]+|history))>>>/g; var MEDIA_AND_SECTION_MARKER_REGEX = /(<<<dotprompt:(?:media:url|section).*?)>>>/g; var RESERVED_METADATA_KEYWORDS = [ // NOTE: KEEP SORTED "config", "description", "ext", "input", "model", "name", "output", "raw", "toolDefs", "tools", "variant", "version" ]; var BASE_METADATA = { ext: {}, metadata: {}, config: {} }; function splitByRegex(source, regex) { return source.split(regex).filter((s) => s.trim() !== ""); } function splitByRoleAndHistoryMarkers(renderedString) { return splitByRegex(renderedString, ROLE_AND_HISTORY_MARKER_REGEX); } function splitByMediaAndSectionMarkers(source) { return splitByRegex(source, MEDIA_AND_SECTION_MARKER_REGEX); } function convertNamespacedEntryToNestedObject(key, value, obj = {}) { const result = obj || {}; const lastDotIndex = key.lastIndexOf("."); const ns = key.substring(0, lastDotIndex); const field = key.substring(lastDotIndex + 1); result[ns] = result[ns] || {}; result[ns][field] = value; return result; } function extractFrontmatterAndBody(source) { const match = source.match(FRONTMATTER_AND_BODY_REGEX); if (match) { const [, frontmatter, body] = match; return { frontmatter, body }; } return { frontmatter: "", body: "" }; } function parseDocument(source) { const { frontmatter, body } = extractFrontmatterAndBody(source); if (frontmatter) { try { const parsedMetadata = parse(frontmatter); const raw = { ...parsedMetadata }; const pruned = { ...BASE_METADATA }; const ext = {}; for (const k in raw) { const key = k; if (RESERVED_METADATA_KEYWORDS.includes(key)) { pruned[key] = raw[key]; } else if (key.includes(".")) { convertNamespacedEntryToNestedObject(key, raw[key], ext); } } return { ...pruned, raw, ext, template: body.trim() }; } catch (error) { console.error("Dotprompt: Error parsing YAML frontmatter:", error); return { ...BASE_METADATA, template: source.trim() }; } } return { ...BASE_METADATA, template: source }; } function messageSourcesToMessages(messageSources) { return messageSources.filter((ms) => ms.content || ms.source).map((m) => { const out = { role: m.role, content: m.content || toParts(m.source || "") }; if (m.metadata) { out.metadata = m.metadata; } return out; }); } function transformMessagesToHistory(messages) { return messages.map((m) => ({ ...m, metadata: { ...m.metadata, purpose: "history" } })); } function toMessages(renderedString, data) { let currentMessage = { role: "user", source: "" }; const messageSources = [currentMessage]; for (const piece of splitByRoleAndHistoryMarkers(renderedString)) { if (piece.startsWith(ROLE_MARKER_PREFIX)) { const role2 = piece.substring(ROLE_MARKER_PREFIX.length); if (currentMessage.source?.trim()) { currentMessage = { role: role2, source: "" }; messageSources.push(currentMessage); } else { currentMessage.role = role2; } } else if (piece.startsWith(HISTORY_MARKER_PREFIX)) { const historyMessages = transformMessagesToHistory(data?.messages ?? []); if (historyMessages) { messageSources.push(...historyMessages); } currentMessage = { role: "model", source: "" }; messageSources.push(currentMessage); } else { currentMessage.source += piece; } } const messages = messageSourcesToMessages(messageSources); return insertHistory(messages, data?.messages); } function messagesHaveHistory(messages) { return messages.some((m) => m.metadata?.purpose === "history"); } function insertHistory(messages, history2 = []) { if (!history2 || messagesHaveHistory(messages)) { return messages; } if (messages.length === 0) { return history2; } const lastMessage = messages.at(-1); if (lastMessage?.role === "user") { const messagesWithoutLast = messages.slice(0, -1); return [...messagesWithoutLast, ...history2, lastMessage]; } return [...messages, ...history2]; } function toParts(source) { return splitByMediaAndSectionMarkers(source).map(parsePart); } function parsePart(piece) { if (piece.startsWith(MEDIA_MARKER_PREFIX)) { return parseMediaPart(piece); } if (piece.startsWith(SECTION_MARKER_PREFIX)) { return parseSectionPart(piece); } return parseTextPart(piece); } function parseMediaPart(piece) { if (!piece.startsWith(MEDIA_MARKER_PREFIX)) { throw new Error("Invalid media piece"); } const [_, url, contentType] = piece.split(" "); const part = { media: { url } }; if (contentType) { part.media.contentType = contentType; } return part; } function parseSectionPart(piece) { if (!piece.startsWith(SECTION_MARKER_PREFIX)) { throw new Error("Invalid section piece"); } const [_, sectionType] = piece.split(" "); return { metadata: { purpose: sectionType, pending: true } }; } function parseTextPart(piece) { return { text: piece }; } // src/picoschema.ts var JSON_SCHEMA_SCALAR_TYPES = [ "any", "boolean", "integer", "null", "number", "string" ]; var WILDCARD_PROPERTY_NAME = "(*)"; async function picoschema(schema, options) { return new PicoschemaParser(options).parse(schema); } var PicoschemaParser = class { schemaResolver; /** * Constructs a new PicoschemaParser. * * @param options The options for the parser. */ constructor(options) { this.schemaResolver = options?.schemaResolver; } /** * Resolves a named schema using the configured resolver. * * @param schemaName The name of the schema to resolve. * @return The resolved JSON Schema. */ async mustResolveSchema(schemaName) { if (!this.schemaResolver) { throw new Error(`Picoschema: unsupported scalar type '${schemaName}'.`); } const val = await this.schemaResolver(schemaName); if (!val) { throw new Error( `Picoschema: could not find schema with name '${schemaName}'` ); } return val; } /** * Parses a schema, detecting if it's Picoschema or JSON Schema. * * @param schema The schema definition to parse. * @return The resulting JSON Schema, or null if the input is null. */ async parse(schema) { if (!schema) { return null; } if (typeof schema === "string") { const [type, description] = extractDescription(schema); if (JSON_SCHEMA_SCALAR_TYPES.includes(type)) { let out = { type }; if (description) { out = { ...out, description }; } return out; } const resolvedSchema = await this.mustResolveSchema(type); return description ? { ...resolvedSchema, description } : resolvedSchema; } if ([...JSON_SCHEMA_SCALAR_TYPES, "object", "array"].includes( schema?.type )) { return schema; } if (typeof schema?.properties === "object") { return { ...schema, type: "object" }; } return this.parsePico(schema); } /** * Parses a Picoschema object or string fragment. * * @param obj The object or string fragment to parse. * @param path The current path within the schema structure. * @return The parsed JSON Schema. */ async parsePico(obj, path = []) { if (typeof obj === "string") { const [type, description] = extractDescription(obj); if (!JSON_SCHEMA_SCALAR_TYPES.includes(type)) { let resolvedSchema = await this.mustResolveSchema(type); if (description) resolvedSchema = { ...resolvedSchema, description }; return resolvedSchema; } if (type === "any") { return description ? { description } : {}; } return description ? { type, description } : { type }; } if (typeof obj !== "object") { throw new Error( `Picoschema: only consists of objects and strings. Got: ${JSON.stringify(obj)}` ); } const schema = { type: "object", properties: {}, required: [], additionalProperties: false }; for (const key in obj) { if (key === WILDCARD_PROPERTY_NAME) { schema.additionalProperties = await this.parsePico(obj[key], [ ...path, key ]); continue; } const [name, typeInfo] = key.split("("); const isOptional = name.endsWith("?"); const propertyName = isOptional ? name.slice(0, -1) : name; if (!isOptional) { schema.required.push(propertyName); } if (!typeInfo) { const prop = { ...await this.parsePico(obj[key], [...path, key]) }; if (isOptional && typeof prop.type === "string") { prop.type = [prop.type, "null"]; } schema.properties[propertyName] = prop; continue; } const [type, description] = extractDescription( typeInfo.substring(0, typeInfo.length - 1) ); if (type === "array") { schema.properties[propertyName] = { type: isOptional ? ["array", "null"] : "array", items: await this.parsePico(obj[key], [...path, key]) }; } else if (type === "object") { const prop = await this.parsePico(obj[key], [...path, key]); if (isOptional) prop.type = [prop.type, "null"]; schema.properties[propertyName] = prop; } else if (type === "enum") { const prop = { enum: obj[key] }; if (isOptional && !prop.enum.includes(null)) prop.enum.push(null); schema.properties[propertyName] = prop; } else { throw new Error( `Picoschema: parenthetical types must be 'object' or 'array', got: ${type}` ); } if (description) { schema.properties[propertyName].description = description; } } if (!schema.required.length) { schema.required = void 0; } return schema; } }; function extractDescription(input) { if (!input.includes(",")) { return [input, null]; } const match = input.match(/(.*?), *(.*)$/); if (!match) { return [input, null]; } return [match[1], match[2]]; } // src/util.ts function removeUndefinedFields(obj) { if (obj === null || typeof obj !== "object") { return obj; } if (Array.isArray(obj)) { return obj.map((item) => removeUndefinedFields(item)); } const result = {}; for (const [key, value] of Object.entries(obj)) { if (value !== void 0) { result[key] = removeUndefinedFields(value); } } return result; } // src/dotprompt.ts var Dotprompt = class { handlebars; knownHelpers = {}; defaultModel; modelConfigs = {}; tools = {}; toolResolver; schemas = {}; schemaResolver; partialResolver; store; constructor(options) { 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(helpers_exports, 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, fn) { 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, source) { 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) { 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(source) { return parseDocument(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(source, data = {}, options) { const renderer = await this.compile(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(source, additionalMetadata) { let parsedSource; if (typeof source === "string") { parsedSource = this.parse(source); } else { parsedSource = source; } if (additionalMetadata) { parsedSource = { ...parsedSource, ...additionalMetadata }; } await this.resolvePartials(parsedSource.template); const renderString = this.handlebars.compile( parsedSource.template, { knownHelpers: this.knownHelpers, knownHelpersOnly: true, noEscape: true } ); const renderFunc = async (data, options) => { 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(renderedString, data) }; }; renderFunc.prompt = parsedSource; return renderFunc; } /** * 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(source, additionalMetadata) { let parsedSource; if (typeof source === "string") { parsedSource = this.parse(source); } else { parsedSource = source; } const model = additionalMetadata?.model || parsedSource.model || this.defaultModel; let modelConfig; if (model && this.modelConfigs[model]) { modelConfig = this.modelConfigs[model]; } return this.resolveMetadata( 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 */ async resolveMetadata(base, ...merges) { let out = { ...base }; for (let i = 0; i < merges.length; i++) { if (!merges[i]) { continue; } const originalConfig = out.config || {}; out = { ...out, ...merges[i] }; out.config = { ...originalConfig, ...merges[i]?.config || {} }; } const { template: _, ...outWithoutTemplate } = out; out = outWithoutTemplate; out = removeUndefinedFields(out); 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 */ async renderPicoschema(meta) { if (!meta.output?.schema && !meta.input?.schema) { return meta; } const resolveSchema = (schema) => { return picoschema(schema, { schemaResolver: this.wrappedSchemaResolver.bind(this) }); }; const newMeta = { ...meta }; let inputPromise = null; let outputPromise = null; 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); } 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 */ async wrappedSchemaResolver(name) { 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 */ async resolveTools(base) { const out = { ...base }; if (!out.tools) { return out; } const unregisteredNames = []; out.toolDefs = out.toolDefs || []; await Promise.all( out.tools.map(async (name) => { if (this.tools[name]) { if (out.toolDefs) { out.toolDefs.push(this.tools[name]); } } else if (this.toolResolver) { 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 { 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 */ identifyPartials(template) { const ast = this.handlebars.parse(template); const partials = /* @__PURE__ */ new Set(); const visitor = new class extends this.handlebars.Visitor { // Visit partial statements and add their names to our set. PartialStatement(partial) { 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 */ async resolvePartials(template) { if (!this.partialResolver && !this.store) { return; } const names = this.identifyPartials(template); await Promise.all( Array.from(names).map(async (name) => { if (!this.handlebars.partials[name]) { let content = 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); await this.resolvePartials(content); } } }) ); } /** * Registers initial helpers from built-in helpers and options. * @private */ registerInitialHelpers(builtinHelpers, customHelpers) { if (builtinHelpers) { for (const key in builtinHelpers) { this.defineHelper( key, builtinHelpers[key] ); } } 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 */ registerInitialPartials(partials) { if (partials) { for (const key in partials) { this.definePartial(key, partials[key]); } } } }; export { Dotprompt, PicoschemaParser, picoschema };