/** * 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 }; 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, 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)[p]; } return cur; } /** {{var}} 模板替換(top-level vars 物件) */ function interpolate(template: string, vars: Record): string { return template.replace(/\{\{(\w+)\}\}/g, (_, key) => (vars[key] !== undefined ? vars[key] : `{{${key}}}`)); } async function fetchKbdbBlock( env: ExpanderEnv, apiKey: string, fragment: Extract, ): Promise { 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; // page_name 模式回 {blocks:[]},block_id 模式直接回 block 物件 const block: Record = fragment.block_id ? data : ((data.blocks as unknown[])?.[0] as Record) ?? {}; 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): { 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, env: ExpanderEnv, apiKey: string, // KBDB partner key(從 workflow auth 來) ): Promise { const recipe = await loadPromptRecipe(recipeRef, env.RECIPES); const vars: Record = {}; 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 };