922a57fe34
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>
137 lines
5.1 KiB
TypeScript
137 lines
5.1 KiB
TypeScript
/**
|
||
* 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 prompt(system + 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 };
|