diff --git a/bun.lockb b/bun.lockb index ba9f807..b705b32 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/data/prompt-templates/graph/test.liquid b/data/prompt-templates/graph/test.liquid index 039a644..726b500 100644 --- a/data/prompt-templates/graph/test.liquid +++ b/data/prompt-templates/graph/test.liquid @@ -13,8 +13,6 @@ Generate a {% if max_chars < 1000 %}short{% endif %} fiction text of max. {% wor EXAMPLES: {{ examples }} -{% field FORMAT = "{ label: 'Format', default: 'Blog post' }" %} - --- AFTER write fiction --- {% if prev_generation_chars > max_chars %} {% goto shorten %} @@ -30,12 +28,9 @@ Shorten the text following text to {% chars_to_words assign='{{ max_chars }}' %} {{ prev_generation_text }} -{% field ASPECT = "{ label: 'Aspect', default: 'Foo' }" %} -{% field ASPECT2 = "{ label: 'Aspect2', default: 'Foo' }" %} - --- AFTER shorten --- {% if prev_generation_chars > max_chars %} - {% goto shorten %} // FIXME: repeat! + {% goto shorten %} {% else %} {% done %} {% endif %} diff --git a/examples/graph.ts b/examples/graph.ts index c33bb40..5667d0c 100644 --- a/examples/graph.ts +++ b/examples/graph.ts @@ -1,5 +1,6 @@ import { plan } from "../src" import { readFileSync } from "fs" +import prettyjson from "prettyjson" const graphPrompt = readFileSync("data/prompt-templates/graph/test.liquid", "utf-8") @@ -9,18 +10,37 @@ const result = await plan(graphPrompt, { user_context: 'Foo bar', }, { tags: { - test: async(tagName, opts, values, instance) => { - console.log('test mocking for', opts.promptLabel) - + + inputMock: async(tagName, ctx, input, opts, instance) => { + console.log('input mocking for', ctx.prompt) + switch (ctx.prompt) { + case "write fiction": + ctx.input = { + ...ctx.input, + LALA: 123 + } + break; + } }, - chars_to_words: async(tagName, opts, values, instance) => { - - console.log('tagName', tagName) - console.log('opts', opts) - console.log('values', values) - console.log('instance', instance) - - return 'chars_to_words' + + // provide test mode mock data for each prompt + mock: async(tagName, ctx, input, opts, instance) => { + console.log('test mocking for', ctx.prompt) + switch (ctx.prompt) { + case "write fiction": + ctx.output = { + ...ctx.output, + CONTROL_FLOW_RESULT: "This is a really beautiful fictional story on how a developer saved the world from a bug by doing alot of coding, using LLMs, etc." + } + break; + + case "shorten": + ctx.output = { + ...ctx.output, + CONTROL_FLOW_RESULT: "Fictional Story: Developer saved the world from a bug." + } + break; + } } }, }) @@ -31,4 +51,4 @@ const result = await plan(graphPrompt, { // runStep(index, { stream: true }) // runWorkflow({ stream: true }) -console.log('result', result) \ No newline at end of file +console.log(prettyjson.render(result)) \ No newline at end of file diff --git a/package.json b/package.json index 1656853..443df53 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@biomejs/biome": "^1.6.4", "dotenv": "^16.4.5", "pkgroll": "^2.1.1", + "prettyjson": "^1.2.5", "spawn-please": "^3.0.0", "tsx": "^4.16.2", "typescript": "^5.0.0", diff --git a/src/interfaces.ts b/src/interfaces.ts index ca4fb5a..1366e43 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -17,16 +17,6 @@ export type PromptInstruction = "PROMPT" | "AFTER"; export type FieldMap = Record; -export type PromptStep = { - promptTemplate: string; - label?: string; - inputValues: StringMap; - outputValues: StringMap; - prompt: string; - error?: unknown; - instruction?: PromptInstruction; -}; - export type PromptNodeType = "preamble" | "template"; export interface PromptNode { @@ -38,41 +28,45 @@ export interface PromptNode { export type PromptList = Array; -export type LiquiPrompt = Array; - export interface PromptContext { - promptLabel: string; // FIXME: prompt - inputValues: PromptInput; // FIXME: input - outputValues: StringMap; // FIXME: output + prompt: string; + input: PromptInput; + output: StringMap; } export type PromptExecutionMode = "plan" | "test" | "run"; export interface PromptOptions { mode?: PromptExecutionMode; - tags?: TagRegistrationMap; + tags?: TagRegistrationMap; + syncTags?: TagRegistrationMap; } export type TagRegisterFn = ( name: string, ctx: PromptContext, opts: PromptOptions, + tagFn: SyncTagFn | AsyncTagFn, ) => TagClass | TagImplOptions; -export type TagRegistrationMap = Record; +export type TagRegistrationMap = Record; export interface DefaultTag { hash: Hash; fieldIndex: number; } -export type TagFn = ( +export type TagFn = ( name: string, ctx: PromptContext, values: StringMap, opts: PromptOptions, instance: DefaultTag, -) => Promise | Promise | string | void; +) => T; + +export type AsyncTagFn = TagFn | Promise>; +// biome-ignore lint/suspicious/noConfusingVoidType: necessary for tags with no return value +export type SyncTagFn = TagFn; export type ValueExpression = { value?: string; @@ -81,3 +75,24 @@ export type ValueExpression = { }; export type TagHash = { [key: string]: string | boolean | number }; + +export interface PromptParsed { + name: string; + instruction?: string; + tpl: string; + input: StringMap; + output: StringMap; + prompt: string; + error?: string; +} + +export type LiquiPrompt = Array; + +export interface PlanModeResult { + prompts: Array; + errors: Array; + /** output variables in "plan" mode aggregate (merge) all output variables of all steps that have been visited by the compiler, + * following each single prompt control flow given the initial input variables. This can be helpful for use-cases where custom tags + * add meta-data over variables used in prompts etc. (see "fields" example) */ + output: StringMap; +} diff --git a/src/parser.ts b/src/parser.ts index 66639f7..f19aa8f 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2,11 +2,14 @@ import { Context, Liquid } from "liquidjs"; import type { PromptInstruction, PromptList, + PromptNode, PromptOptions, + PromptParsed, StringMap, + TagHash, ValueExpression, } from "./interfaces"; -import { registerTags } from "./tags"; +import { builtInTags, registerTags } from "./tags"; /** parses individual prompts together with their leading preambles */ export const parsePrompts = (promptTemplate: string): PromptList => { @@ -26,8 +29,9 @@ export const parsePrompts = (promptTemplate: string): PromptList => { if (currentText) { nodes.push({ type: "template", + instruction: currentMatch?.[1].trim() as PromptInstruction, template: currentText.trimEnd(), - label: currentMatch?.[2], + label: currentMatch?.[2].trim(), }); currentText = ""; } @@ -35,8 +39,8 @@ export const parsePrompts = (promptTemplate: string): PromptList => { // extract preamble nodes.push({ type: "preamble", - instruction: match[1] as PromptInstruction, - label: match[2], + instruction: match[1].trim() as PromptInstruction, + label: match[2].trim(), }); currentMatch = match; } else { @@ -47,85 +51,143 @@ export const parsePrompts = (promptTemplate: string): PromptList => { if (currentText.trimEnd().length > 0) { nodes.push({ type: "template", + instruction: (currentMatch?.[1] || "").trim() as PromptInstruction, template: currentText.trimEnd(), - label: currentMatch?.[2], + label: (currentMatch?.[2] || "").trim(), }); currentText = ""; } + return nodes.length ? nodes : [ { type: "template", + instruction: (currentMatch?.[1] || "").trim() as PromptInstruction, template: promptTemplate, - label: currentMatch?.[2], + label: (currentMatch?.[2] || "").trim(), }, ]; }; export const parseSingle = async ( - promptLabel: string, - promptTemplate: string, - inputValues: StringMap, + promptNode: PromptNode, + input: StringMap, opts: PromptOptions, -) => { - let prompt = ""; +): Promise => { + let renderedPrompt = ""; let error; - const outputValues: StringMap = {}; + const output: StringMap = {}; const ctx = new Context(); + let tpl = promptNode.template || ""; + const prompt = promptNode.label || ""; const promptContext = { - promptLabel, - inputValues, - outputValues, + prompt, + input, + output, }; try { - let engine = new Liquid(); + const engine = new Liquid(); + + // register built-in dialect tags + registerTags(engine, promptContext, opts, builtInTags, "sync"); - // register custom tags (dialect) - engine = registerTags(engine, promptContext, opts); + // user-defined tags (sync) + registerTags(engine, promptContext, opts, opts.syncTags || {}, "sync"); + + // user-defined tags (async) + registerTags(engine, promptContext, opts, opts.tags || {}, "async"); ctx.globals = { - ...inputValues, + ...input, }; + // FIXME: only when mode is "test" + if ( // test mocking is implemented, but not used in the prompt - typeof opts.tags?.test === "function" && - // FIXME: use regexp - promptTemplate.indexOf("{% test %}") === -1 + typeof opts.tags?.inputMock === "function" && + tpl.length > 0 && + !/{%\s*inputMock\s*%}/i.test(tpl) ) { - // every prompt should have a test block to allow mocking the CONTROL_FLOW_RESULT value - promptTemplate += "\n{% test %}"; + // every prompt can have a inputMock block to allow mocking the input values for logic to be tested + tpl = `{% inputMock %}\n${tpl}`; } - prompt = (await engine.parseAndRender(promptTemplate, ctx)).trim(); + if ( + // test mocking is implemented, but not used in the prompt + typeof opts.tags?.mock === "function" && + tpl.length > 0 && + !/{%\s*mock\s*%}/.test(tpl) + ) { + // every prompt can have a mock block to allow mocking the CONTROL_FLOW_RESULT value + tpl = `${tpl}\n{% mock %}`; + } + + renderedPrompt = (await engine.parseAndRender(tpl, ctx)).trim(); } catch (e) { - error = (e as Error).message; + error = `In ${promptNode.instruction}, ${prompt}: ${(e as Error).message}`; } + console.log("ctx.globals", ctx.getAll()); + return { - promptTemplate, - inputValues, - outputValues: promptContext.outputValues, - prompt, + name: prompt, + tpl, + input, + output, + prompt: renderedPrompt, error, }; }; /** unpacks variable name frim */ -export const parseValueExpression = (expression: string): ValueExpression => { - // FIXME: whitespaces should be optional - const matches = expression.match(/^\{\{\s+(.+)\s+\}\}$/); +export const parseValueExpression = ( + expressionOrValue: string, +): ValueExpression => { + const matches = expressionOrValue.match(/^\{\{\s*(.+)\s*\}\}$/); if (!matches) { return { - value: expression, + value: expressionOrValue, // original value type: "value", - }; // value + }; } return { - label: matches[1], // variable name + label: matches[1].trim(), // sanitized variable name type: "variable", }; }; + +export const parseTagHashValues = ( + hash: TagHash, + outputValues: StringMap, + inputValues: StringMap, +): StringMap => { + const keys = Object.keys(hash); + const values: StringMap = {}; + + // generalized hash value allocation including input parameter lookup + for (const key of keys) { + if (typeof hash[key] === "undefined" || typeof hash[key] !== "string") { + values[key] = hash[key]; // assign original value + continue; + } + + const valueExpression = parseValueExpression(hash[key] as string); + + // output variables precede in priority (closer context) + if (valueExpression.type === "variable") { + valueExpression.value = outputValues[valueExpression.label!]; + + // if no output value can be found as variable, try to use an input variable + if (typeof valueExpression.value === "undefined") { + valueExpression.value = inputValues[valueExpression.label!]; + } + } + // assign value, original value or empty string + values[key] = valueExpression.value || ""; + } + return values; +}; diff --git a/src/prompt.ts b/src/prompt.ts index f35d017..237ec87 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -1,58 +1,71 @@ -import type { StringMap, PromptStep, PromptOptions } from "./interfaces"; +import type { + StringMap, + PromptOptions, + PromptParsed, + PlanModeResult, +} from "./interfaces"; import { parsePrompts, parseSingle } from "./parser"; -export const defaultParseOpts: PromptOptions = { +export const defaultOpts: PromptOptions = { tags: {}, + syncTags: {}, }; /** preprocessor for meta data followed by liquid compilation pass */ export const plan = async ( - promptTemplate: string, - inputValues: StringMap = {}, - parseOpts: PromptOptions = defaultParseOpts, -): Promise> => { - parseOpts = { - ...defaultParseOpts, - ...parseOpts, + tpl: string, + input: StringMap = {}, + opts: PromptOptions = defaultOpts, +): Promise => { + opts = { + ...defaultOpts, + ...opts, mode: "plan", }; - const promptList = parsePrompts(promptTemplate).reverse(); + const promptList = parsePrompts(tpl).reverse(); - const parseResults: Array = []; + const parseResults: Array = []; let prevSinglePromptResultIndex = -1; // reverse for simple association for (let i = 0; i < promptList.length; i++) { const singlePrompt = promptList[i]; - let singlePromptResult: PromptStep; + let parsed: PromptParsed; if (singlePrompt.type === "template") { - singlePromptResult = await parseSingle( - singlePrompt.label || "", - singlePrompt.template || "", - inputValues, - parseOpts, - ); - singlePromptResult.instruction = "PROMPT"; - singlePromptResult.label = singlePrompt.label; - parseResults.push(singlePromptResult); + parsed = await parseSingle(singlePrompt, input, opts); + parsed.instruction = "PROMPT"; + parseResults.push(parsed); prevSinglePromptResultIndex = parseResults.length - 1; } else if ( singlePrompt.type === "preamble" && singlePrompt.instruction === "AFTER" && parseResults[prevSinglePromptResultIndex] ) { - parseResults[prevSinglePromptResultIndex].label = singlePrompt.label; + parseResults[prevSinglePromptResultIndex].name = singlePrompt.label || ""; parseResults[prevSinglePromptResultIndex].instruction = "AFTER"; } else if ( singlePrompt.type === "preamble" && singlePrompt.instruction === "PROMPT" && parseResults[prevSinglePromptResultIndex] ) { - parseResults[prevSinglePromptResultIndex].label = singlePrompt.label; + parseResults[prevSinglePromptResultIndex].name = singlePrompt.label || ""; parseResults[prevSinglePromptResultIndex].instruction = "PROMPT"; } } - return parseResults.reverse().filter((node) => node.promptTemplate); + + const sanizizedOrderedPromptParseResults = parseResults + .reverse() + .filter((node) => node.tpl); + + return { + prompts: sanizizedOrderedPromptParseResults, + errors: sanizizedOrderedPromptParseResults + .map((node) => node.error || "") + .filter(Boolean), + output: sanizizedOrderedPromptParseResults.reduce((acc, node) => { + return { ...acc, ...node.output }; + }, {}), + }; }; diff --git a/src/tags.ts b/src/tags.ts index 3070b22..0665eac 100644 --- a/src/tags.ts +++ b/src/tags.ts @@ -7,10 +7,12 @@ import { type TopLevelToken, } from "liquidjs"; import type { + AsyncTagFn, DefaultTag, PromptContext, PromptOptions, StringMap, + SyncTagFn, TagHash, TagRegisterFn, TagRegistrationMap, @@ -20,10 +22,10 @@ import { defineGotoTag } from "./tags/goto"; import { defineDoneTag } from "./tags/done"; import { defineWordcountTag } from "./tags/wordcount"; import { defineExamplesTag } from "./tags/examples"; -import { parseValueExpression } from "./parser"; +import { parseTagHashValues } from "./parser"; import { runGenerator } from "./generator"; -export const builtInTags: TagRegistrationMap = { +export const builtInTags: TagRegistrationMap = { field: defineFieldTag, goto: defineGotoTag, done: defineDoneTag, @@ -33,57 +35,23 @@ export const builtInTags: TagRegistrationMap = { export const registerTags = ( engine: Liquid, - opts: PromptContext, - parseOpts: PromptOptions, + ctx: PromptContext, + opts: PromptOptions, + tags: TagRegistrationMap, + type: "async" | "sync", ) => { - // TODO: register tags by sync or async, default async, combined + const registerFunction = type === "async" ? defineAsyncTag : defineSyncTag; try { - const tagNames = Object.keys(builtInTags); - - // register built-in tags (sync) + const tagNames = Object.keys(tags); for (const tagName of tagNames) { - engine.registerTag(tagName, defineSyncTag(tagName, opts, parseOpts)); + engine.registerTag( + tagName, + registerFunction(tagName, ctx, opts, tags[tagName]), + ); } } catch (e) { console.error("ERROR registerTags", e); } - - // register custom tags (async) - Object.keys(parseOpts.tags || []).forEach((tagName) => { - engine.registerTag(tagName, defineAsyncTag(tagName, opts, parseOpts)); - }); - return engine; -}; - -export const parseTagHashValues = ( - hash: TagHash, - outputValues: StringMap, - inputValues: StringMap, -): StringMap => { - const keys = Object.keys(hash); - const values: StringMap = {}; - - // generalized hash value allocation including input parameter lookup - for (const key of keys) { - if (typeof hash[key] === "undefined" || typeof hash[key] !== "string") { - continue; - } - - const valueExpression = parseValueExpression(hash[key] as string); - - // output variables precede in priority (closer context) - if (valueExpression.type === "variable") { - valueExpression.value = outputValues[valueExpression.label!]; - - // if no output value can be found as variable, try to use an input variable - if (typeof valueExpression.value === "undefined") { - valueExpression.value = inputValues[valueExpression.label!]; - } - } - // assign value, original value or empty string - values[key] = valueExpression.value || ""; - } - return values; }; export const getHash = (tagTokenValue: string): Hash => { @@ -106,9 +74,10 @@ export const getHash = (tagTokenValue: string): Hash => { }; export const defineSyncTag: TagRegisterFn = ( - tagName: string, + tagName, defineTagOpts, parseOpts, + tagFn, ) => class BuiltInTag extends Tag implements DefaultTag { hash: Hash; @@ -125,13 +94,13 @@ export const defineSyncTag: TagRegisterFn = ( *render(ctx: Context) { const values = parseTagHashValues( yield this.hash.render(ctx), - defineTagOpts.outputValues, - defineTagOpts.inputValues, + defineTagOpts.output, + defineTagOpts.input, ); let renderString = ""; - if (typeof builtInTags[tagName] === "function") { - renderString = builtInTags[tagName]( + if (typeof tagFn === "function") { + renderString = tagFn( tagName, defineTagOpts, values, @@ -147,6 +116,7 @@ export const defineAsyncTag: TagRegisterFn = ( tagName: string, defineTagOpts, parseOpts, + tagFn, ) => class CustomTag extends Tag implements DefaultTag { hash: Hash; @@ -179,20 +149,14 @@ export const defineAsyncTag: TagRegisterFn = ( const values = parseTagHashValues( tagHash, - defineTagOpts.outputValues, - defineTagOpts.inputValues, + defineTagOpts.output, + defineTagOpts.input, ); let renderString = ""; - if (typeof parseOpts.tags![tagName] === "function") { + if (typeof tagFn === "function") { renderString = - (await parseOpts.tags![tagName]( - tagName, - defineTagOpts, - values, - parseOpts, - this, - )) || ""; + (await tagFn(tagName, defineTagOpts, values, parseOpts, this)) || ""; } return renderString || ""; } diff --git a/src/tags/done.ts b/src/tags/done.ts index a242080..597f46c 100644 --- a/src/tags/done.ts +++ b/src/tags/done.ts @@ -1,8 +1,8 @@ -import type { TagFn } from "../interfaces"; +import type { SyncTagFn } from "../interfaces"; /** {% done %} */ -export const defineDoneTag: TagFn = (_tagName, ctx) => { - ctx.outputValues.CONTROL_FLOW_DONE = true; +export const defineDoneTag: SyncTagFn = (_tagName, ctx) => { + ctx.output.CONTROL_FLOW_DONE = true; }; diff --git a/src/tags/examples.ts b/src/tags/examples.ts index 8007590..76e60ff 100644 --- a/src/tags/examples.ts +++ b/src/tags/examples.ts @@ -1,9 +1,9 @@ -import type { TagFn } from "../interfaces"; +import type { SyncTagFn } from "../interfaces"; /** {% examples query='{{ user_context }}' count=3 %} */ -export const defineExamplesTag: TagFn = async (_tagName, _ctx, values) => { - // FIXME: count is undefined +export const defineExamplesTag: SyncTagFn = (_tagName, _ctx, values) => { console.log("Load examples from", values.query, "count", values.count); + return "asd"; }; diff --git a/src/tags/field.ts b/src/tags/field.ts index 8da247d..e18fab9 100644 --- a/src/tags/field.ts +++ b/src/tags/field.ts @@ -1,5 +1,5 @@ import JSON5 from "json5"; -import type { FieldParseResult, TagFn } from "../interfaces"; +import type { FieldParseResult, SyncTagFn } from "../interfaces"; export const defaultMeta = ( meta: Record, @@ -45,7 +45,7 @@ export const autoDetectType = (value: FieldParseResult): string => { {% field FIELD_NAME_2 = "{ options: ['bar', 'baz'] }" %} */ -export const defineFieldTag: TagFn = ( +export const defineFieldTag: SyncTagFn = ( _tagName, ctx, values, @@ -63,29 +63,28 @@ export const defineFieldTag: TagFn = ( value.key = key; // evaluate default value - ctx.outputValues[key] = ctx.inputValues[key] ?? value.default; + ctx.output[key] = ctx.input[key] ?? value.default; - if (!ctx.outputValues.PROMPT_FIELDS) { - ctx.outputValues.PROMPT_FIELDS = {}; + if (!ctx.output.PROMPT_FIELDS) { + ctx.output.PROMPT_FIELDS = {}; } - defaultMeta(ctx.outputValues.PROMPT_FIELDS, key); + defaultMeta(ctx.output.PROMPT_FIELDS, key); // meta data merge - ctx.outputValues.PROMPT_FIELDS[key].default = value.default; + ctx.output.PROMPT_FIELDS[key].default = value.default; - ctx.outputValues.PROMPT_FIELDS[key].label = value.label ?? key; + ctx.output.PROMPT_FIELDS[key].label = value.label ?? key; - ctx.outputValues.PROMPT_FIELDS[key].type = - value.type ?? autoDetectType(value); + ctx.output.PROMPT_FIELDS[key].type = value.type ?? autoDetectType(value); - ctx.outputValues.PROMPT_FIELDS[key].order = instance.fieldIndex; + ctx.output.PROMPT_FIELDS[key].order = instance.fieldIndex; if (value.options && Array.isArray(value.options)) { - if (ctx.outputValues.PROMPT_FIELDS[key].default) { - ctx.outputValues.PROMPT_FIELDS[key].default = String(value.options[0]); + if (ctx.output.PROMPT_FIELDS[key].default) { + ctx.output.PROMPT_FIELDS[key].default = String(value.options[0]); } - ctx.outputValues.PROMPT_FIELDS[key].options = value.options.map((v) => + ctx.output.PROMPT_FIELDS[key].options = value.options.map((v) => String(v), ); } diff --git a/src/tags/goto.ts b/src/tags/goto.ts index 20080e5..dd78bf9 100644 --- a/src/tags/goto.ts +++ b/src/tags/goto.ts @@ -1,8 +1,8 @@ -import type { TagFn } from "../interfaces"; +import type { SyncTagFn } from "../interfaces"; /** {% goto shorten %} */ -export const defineGotoTag: TagFn = (_tagName, ctx, values) => { - ctx.outputValues.CONTROL_FLOW_GOTO = Object.keys(values)[0]; +export const defineGotoTag: SyncTagFn = (_tagName, ctx, values) => { + ctx.output.CONTROL_FLOW_GOTO = Object.keys(values)[0]; }; diff --git a/src/tags/wordcount.ts b/src/tags/wordcount.ts index 14eb32e..b85d198 100644 --- a/src/tags/wordcount.ts +++ b/src/tags/wordcount.ts @@ -1,9 +1,9 @@ -import type { TagFn } from "../interfaces"; +import type { SyncTagFn } from "../interfaces"; /** {% wordcount chars='{{ max_chars }}' %} */ -export const defineWordcountTag: TagFn = (_tagName, ctx, values) => { +export const defineWordcountTag: SyncTagFn = (_tagName, ctx, values) => { const chars: number = typeof values.chars === "string" ? Number.parseInt(values.chars) : 0;