Files
Arcrun/cypher-executor/src/lib/recipe-expander.ts
T
uncle6me-web 922a57fe34 arcrun — AI workflow execution engine (clean history)
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。

此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在
richblack/arcrun 與本地 backup 分支)。含:
- acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe)
- recipe push 把關(資料外流提醒 + 打通檢查)
- 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1)
- CLI / cypher-executor / registry / 完整 SDD

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 15:52:38 +08:00

137 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Recipe expander:把 prompt_recipe 展開成 claude_api 的實際 input
* SDD: matrix/arcrun/.agents/specs/recipe-system/design.md §2.2 + Phase 2.1
*
* 流程:
* 1. loadPromptRecipe 取定義
* 2. fragments → 用 KBDB API 抓 block content
* 3. inputs → 從 workflow context 取值 + 跑 transform
* 4. 套進 prompt_assembly.system / .user 的 {{var}} 模板
* 5. 回傳 { prompt, model, output_format, output_required_fields }
*/
import { loadPromptRecipe, RecipeLoadError } from './recipe-loader';
import { applyTransform } from './recipe-transforms';
import type { Fragment, RecipeInput } from './prompt-recipe-schema';
type ExpanderEnv = {
RECIPES: { get: (key: string) => Promise<string | null> };
KBDB_BASE_URL?: string;
};
export interface ExpandedRecipe {
prompt: string; // user promptsystem + user 用 \n\n--- system ---\n 分隔)
model: 'haiku' | 'sonnet' | 'opus';
output_format: 'text' | 'json';
output_required_fields?: string[];
}
/** 從 path 取嵌套值,例如 "ctx.read_drafts.blocks" / "loop.item" */
function getByPath(ctx: Record<string, unknown>, path: string): unknown {
const parts = path.split('.');
let cur: unknown = ctx;
for (const p of parts) {
if (cur === null || cur === undefined) return undefined;
if (typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[p];
}
return cur;
}
/** {{var}} 模板替換(top-level vars 物件) */
function interpolate(template: string, vars: Record<string, string>): string {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (vars[key] !== undefined ? vars[key] : `{{${key}}}`));
}
async function fetchKbdbBlock(
env: ExpanderEnv,
apiKey: string,
fragment: Extract<Fragment, { source: 'kbdb_block' }>,
): Promise<unknown> {
const base = (env.KBDB_BASE_URL ?? 'https://kbdb.finally.click').replace(/\/$/, '');
let url: string;
if (fragment.block_id) {
url = `${base}/blocks/${encodeURIComponent(fragment.block_id)}`;
} else {
url = `${base}/blocks?page_name=${encodeURIComponent(fragment.block_page_name!)}&limit=1`;
}
const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
if (!res.ok) throw new Error(`KBDB fragment 抓取失敗 (${res.status}): ${url}`);
const data = (await res.json()) as Record<string, unknown>;
// page_name 模式回 {blocks:[]}block_id 模式直接回 block 物件
const block: Record<string, unknown> = fragment.block_id
? data
: ((data.blocks as unknown[])?.[0] as Record<string, unknown>) ?? {};
if (!block) throw new Error(`KBDB block 不存在: ${fragment.block_id ?? fragment.block_page_name}`);
const fieldVal = block[fragment.field];
if (fieldVal === undefined) throw new Error(`block 缺欄位 "${fragment.field}"`);
return fieldVal;
}
async function resolveFragment(
env: ExpanderEnv,
apiKey: string,
frag: Fragment,
): Promise<{ var: string; value: unknown }> {
if (frag.source === 'kv') {
const val = await env.RECIPES.get(frag.key);
if (val === null) throw new Error(`KV 找不到 key: ${frag.key}`);
return { var: frag.var, value: val };
}
return { var: frag.var, value: await fetchKbdbBlock(env, apiKey, frag) };
}
function resolveInput(input: RecipeInput, ctx: Record<string, unknown>): { var: string; value: unknown } {
let val = getByPath(ctx, input.from);
const beforeDefault = val;
if (val === undefined) val = input.default;
try {
if (input.transform) val = applyTransform(val, input.transform);
return { var: input.var, value: val };
} catch (e) {
// 把 path 跟原值放進錯誤訊息,方便 debug recipe
const valType = Array.isArray(beforeDefault) ? `array(${beforeDefault.length})`
: beforeDefault === undefined ? 'undefined(default applied)'
: typeof beforeDefault;
throw new Error(`${e instanceof Error ? e.message : String(e)} [path=${input.from}, type=${valType}]`);
}
}
/** 主入口:展開 recipe → 組 prompt */
export async function expandPromptRecipe(
recipeRef: string,
ctx: Record<string, unknown>,
env: ExpanderEnv,
apiKey: string, // KBDB partner key(從 workflow auth 來)
): Promise<ExpandedRecipe> {
const recipe = await loadPromptRecipe(recipeRef, env.RECIPES);
const vars: Record<string, string> = {};
for (const frag of recipe.fragments) {
const { var: name, value } = await resolveFragment(env, apiKey, frag);
vars[name] = typeof value === 'string' ? value : JSON.stringify(value);
}
for (const inp of recipe.inputs) {
const { var: name, value } = resolveInput(inp, ctx);
vars[name] = typeof value === 'string' ? value : JSON.stringify(value);
}
const system = interpolate(recipe.prompt_assembly.system, vars);
const user = interpolate(recipe.prompt_assembly.user, vars);
// claude_api 容器目前吃單一 prompt 字串 → system + user 用分隔線拼
const prompt = `${system}\n\n--- USER ---\n\n${user}`;
return {
prompt,
model: recipe.model,
output_format: recipe.output.format,
output_required_fields: recipe.output.required_fields,
};
}
export { RecipeLoadError };