From 5f5c0a89e20a73bc84c8a26682b2a1905107d72d Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 31 Jul 2026 16:31:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AD=A5=E9=A9=9F5=20=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=E2=91=A1=EF=BC=9Arecipe=20=E8=A3=9C=20payload=EF=BC=8F?= =?UTF-8?q?=E5=9B=9E=E6=87=89=E6=AD=A3=E8=A6=8F=E5=8C=96=EF=BC=8Fbinding?= =?UTF-8?q?=20=E4=B8=89=E5=B1=A4=EF=BC=88leo=20=E4=B8=89=E5=B1=A4=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=9A=84=E7=AC=AC=E2=91=A2=E5=B1=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 問題:舊 recipe schema 只有 {canonical_id, endpoint, method, auth_service}(body 有但淺) ⇒ ①帶 body 的 API 只能繞過 recipe 把整包寫進 workflow code ②回應解析綁死單一供應商(rag_chat 的 finalize 2786 字元全在對付 Gemini 形狀) ③Cloudflare binding(env.AI/VECTORIZE/BROWSER/QUEUE)整類被「只認 HTTP+金鑰」的抽象排除 ⇒ 換 LLM 供應商=改 workflow,而非換 recipe,違背「外部 API 只有一條一致的路」。 新增 lib/recipe-payload.ts(純函式,好測): - renderBodyTemplate:遞迴插值,單一 {{x}} 保留原型別、混合文字拼字串、 支援 dot path、取不到保留原樣(不靜默吞掉,看得見才好 debug)。 語義刻意與 graph-executor 的 interpolateData 一致,不新造第二種插值行為。 - applyResponseMap:text_path 取值/thinking_model 剔除 thought=true 取最後一個非 thought/ answer_marker 用 lastIndexOf(自檢清單內文也會提到標記)/strip_prefixes 循環剝殼 (實撞三型「Draft: 【答】」「* 【答】」「Answer: * 【答】」,單趟剝不乾淨)。 RecipeDefinition 加四個**全選填**欄位:body_template/response_map/auth/binding_name。 - component-loader:body_template 優先於 body,兩者皆無才沿用 ctx 當 body(既有行為) - response_map 有設才附 text 欄,未設原樣回傳 ⇒ 既有 recipe 行為完全不變 - 新增 makeBindingRecipeRunner+pickRecipeRunner:auth='binding' 走平台 binding(免金鑰、 開機即可用),其餘一律走既有 HTTP 路徑。binding 缺綁定/無 run() 時回可操作錯誤,不假綠。 這型不是為 Workers AI 開特例——一次打開 env.AI/VECTORIZE/BROWSER/QUEUE 整排。 payload 用法要「查得到」(同 branch_hint 動機,leo 08-01 的 n8n 式逐顆查): buildPayloadHint() 讓 recipe 的查詢回應說得出「payload 怎麼填、回應怎麼取值、 認證誰負責」,wire 進 discover 混搜/legacy 逐顆/target=recipe 三條路徑。 金鑰鐵律 D36:hint 只說「走 auth recipe X,金鑰由系統注入、你不必也不該填」,不吐值。 測試:tests/recipe-payload-response.test.ts 14 項全綠(含三家形狀 Gemini/Claude/Workers AI 用不同 path 都取得出文字=換源=換 recipe 的實證;未設 response_map 原樣回傳的相容性)。 全套 209 passed(前 179 +30 新),失敗數維持既有 9 筆未變;tsc --noEmit 綠。 SDD: workflow-discovery task 3.12|CP: arcrun-usable 步驟 5 Co-Authored-By: Claude Fable 5 --- cypher-executor/src/actions/search-nodes.ts | 49 ++++++ cypher-executor/src/actions/target-search.ts | 9 +- cypher-executor/src/lib/component-loader.ts | 86 +++++++++- cypher-executor/src/lib/recipe-payload.ts | 154 ++++++++++++++++++ cypher-executor/src/routes/recipes.ts | 26 +++ .../tests/recipe-payload-response.test.ts | 123 ++++++++++++++ 6 files changed, 441 insertions(+), 6 deletions(-) create mode 100644 cypher-executor/src/lib/recipe-payload.ts create mode 100644 cypher-executor/tests/recipe-payload-response.test.ts diff --git a/cypher-executor/src/actions/search-nodes.ts b/cypher-executor/src/actions/search-nodes.ts index 68f48da..d1a192e 100644 --- a/cypher-executor/src/actions/search-nodes.ts +++ b/cypher-executor/src/actions/search-nodes.ts @@ -54,6 +54,18 @@ export type NodeInfo = { /** recipe found 時附上(AI 看得懂這個 recipe 在打哪個 API)。 */ description?: string; endpoint?: string; + /** + * recipe 的 payload/回應用法自我說明(3.12,同 branch_hint 的動機): + * 逐顆查 recipe 時光看 endpoint 不知道「payload 怎麼填、回應怎麼取值」⇒ 會退回寫 code。 + */ + payload_hint?: { + /** 這個 recipe 期望的 body 形狀(body_template 的欄位骨架,值是 {{var}} 佔位) */ + body_template?: unknown; + /** 回應正規化規則存在時,說明取值路徑等 */ + response_map?: unknown; + /** 一行說明:怎麼用這個 recipe */ + usage: string; + }; /** * not_found 時的分型指路(task 3.7):兩庫(零件 registry+recipe 庫)都查過才點名, * 並告訴 AI 該走哪條補件路+去哪裡看做法。欄位名 `suggestion`(單數字串)=verify.sh 03 組契約。 @@ -240,6 +252,7 @@ export async function searchNodes( source: 'recipe', description: recipe.description, endpoint: recipe.endpoint, + payload_hint: buildPayloadHint(recipe), }; continue; } @@ -377,6 +390,7 @@ async function legacyPerNodeLookup( info: { status: 'found', componentId: recipe.canonical_id, type: role, source: 'recipe', description: recipe.description, endpoint: recipe.endpoint, + payload_hint: buildPayloadHint(recipe), }, missing: false, }; @@ -553,6 +567,41 @@ function buildSuggestion(componentId: string): string { ); } +/** + * recipe 的 payload/回應用法自我說明(3.12)。 + * 動機同 branch_hint:逐顆查 recipe(n8n 式)時,光看 endpoint 不知道 payload 怎麼填、 + * 回應怎麼取值 ⇒ AI 會退回把整包寫進 workflow code。 + */ +export function buildPayloadHint(recipe: RecipeDefinition): NodeInfo['payload_hint'] { + const parts: string[] = []; + + if (recipe.body_template) { + parts.push('payload 已收在 recipe 的 body_template 裡,你只要把 {{變數}} 對應的值放進節點 context'); + } else if (recipe.body) { + parts.push('payload 形狀見 body 欄位({{變數}} 由節點 context 填)'); + } else { + parts.push('未定義 body_template:節點 context 會整包當 body 送出(_ 開頭的內部欄位會被剔除)'); + } + + if (recipe.response_map) { + parts.push('回應已正規化:執行結果除了原始 data,另附 text(取值路徑等規則寫在 recipe 裡,換源不必改 workflow)'); + } else { + parts.push('未定義 response_map:回應原樣放在 data,取值要自己指路徑'); + } + + if (recipe.auth === 'binding') { + parts.push(`認證=binding(免金鑰,用平台內建 ${recipe.binding_name ?? 'AI'})`); + } else if (recipe.auth_service) { + parts.push(`認證走 auth recipe「${recipe.auth_service}」(金鑰由系統在執行前注入,你不必也不該填)`); + } + + return { + body_template: recipe.body_template, + response_map: recipe.response_map, + usage: parts.join(';') + '。', + }; +} + // ── registry 查詢 ───────────────────────────────────────────────────────────── type CatalogEntry = { diff --git a/cypher-executor/src/actions/target-search.ts b/cypher-executor/src/actions/target-search.ts index 2010e2b..a7e12b7 100644 --- a/cypher-executor/src/actions/target-search.ts +++ b/cypher-executor/src/actions/target-search.ts @@ -16,7 +16,7 @@ import { wasmWorkerUrl } from '../lib/component-loader'; import { fetchTenantWorkflowSearch } from '../lib/workflow-search'; -import { listAllRecipes, type SearchNodesEnv } from './search-nodes'; +import { listAllRecipes, buildPayloadHint, type SearchNodesEnv } from './search-nodes'; import { branchHintFor } from '../lib/branch-hints'; export type TargetQueryEnv = SearchNodesEnv & { @@ -74,7 +74,10 @@ export async function searchByTarget( const q = query.toLowerCase(); // 與 discover 混搜同一份庫(私庫=workflow 實際引用得到的);子字串比對、canonical 去重 const seen = new Set(); - const results: Array<{ canonical_id: string; display_name?: string; description?: string; endpoint: string }> = []; + const results: Array<{ + canonical_id: string; display_name?: string; description?: string; endpoint: string; + payload_hint?: unknown; + }> = []; for (const r of all) { if (seen.has(r.canonical_id)) continue; const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase(); @@ -85,6 +88,8 @@ export async function searchByTarget( display_name: r.display_name, description: r.description, endpoint: r.endpoint, + // 3.12:逐顆查 recipe 時也要說得出「payload 怎麼填、回應怎麼取值」 + payload_hint: buildPayloadHint(r), }); } return { diff --git a/cypher-executor/src/lib/component-loader.ts b/cypher-executor/src/lib/component-loader.ts index c708807..46315a1 100644 --- a/cypher-executor/src/lib/component-loader.ts +++ b/cypher-executor/src/lib/component-loader.ts @@ -20,6 +20,7 @@ import { isComponentHash, isRecipeHash } from './hash'; import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes'; import type { AuthRecipeDefinition } from '../routes/recipes'; import type { Bindings, ComponentRunner, ServiceBinding } from '../types'; +import { renderBodyTemplate, applyResponseMap } from './recipe-payload'; /** * WASM HTTP runner:canonical_id → 對應獨立 Worker URL。 @@ -120,7 +121,7 @@ export function createComponentLoader(env: Bindings) { // 4. rec_hash → 查 RECIPES KV idx → recipe 執行 if (isRecipeHash(componentId)) { const recipe = await resolveRecipe(componentId, env.RECIPES); - if (recipe) return makeRecipeRunner(recipe); + if (recipe) return pickRecipeRunner(recipe, env); throw new Error(`找不到 recipe hash "${componentId}",請確認已透過 acr push 上傳`); } @@ -134,7 +135,7 @@ export function createComponentLoader(env: Bindings) { // 6. KV recipe(動態,用戶 push 的) const kvRecipe = await resolveRecipe(componentId, env.RECIPES); - if (kvRecipe) return makeRecipeRunner(kvRecipe); + if (kvRecipe) return pickRecipeRunner(kvRecipe, env); // 7. WASM HTTP runner:auth primitive / API 零件 → 獨立 Worker URL // 白名單見 WASM_HTTP_RUNNER_IDS(http_request、5 個待降級 API 零件、4 個 auth primitive)。 @@ -271,6 +272,73 @@ function makeLogicRunner(canonicalId: string, env: Bindings): ComponentRunner | return makeHttpRunner(wasmWorkerUrl(canonicalId, env.WORKER_SUBDOMAIN)); } +/** + * recipe → runner 的分派(3.12):auth='binding' 走平台 binding(免金鑰), + * 其餘一律走既有 HTTP 路徑(沒宣告 auth 的舊 recipe 完全不受影響)。 + */ +function pickRecipeRunner( + recipe: import('../routes/recipes').RecipeDefinition, + env: Bindings, +): ComponentRunner { + return recipe.auth === 'binding' + ? makeBindingRecipeRunner(recipe, env) + : makeRecipeRunner(recipe); +} + +/** + * auth='binding' 的 recipe runner(3.12 第四型認證):不打外部 HTTP、不需要任何金鑰, + * 直接用平台 binding(env.AI/VECTORIZE/…)⇒ leo 要的「開機就可用」。 + * + * 為什麼要開這型:recipe 的舊抽象=「打一個外部 HTTP API」(endpoint+method+auth_service), + * 而 Cloudflare 的 binding 呼叫不是 HTTP ⇒ **整類能力被排除在 recipe 之外**。 + * 開這一型不是為 Workers AI 開特例,是一次打開 env.AI/VECTORIZE/BROWSER/QUEUE 整排。 + */ +function makeBindingRecipeRunner( + recipe: import('../routes/recipes').RecipeDefinition, + env: Bindings, +): ComponentRunner { + return async (ctx: unknown) => { + const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record : {}; + const name = recipe.binding_name ?? 'AI'; + const binding = (env as unknown as Record)[name]; + + if (!binding) { + return { + success: false, + error: + `recipe "${recipe.canonical_id}" 宣告 auth: binding、binding_name: "${name}",` + + `但這個部署沒有綁定 ${name}。請在 wrangler.toml 補上該 binding 後重新部署。`, + }; + } + + // endpoint 在 binding 型當作「要呼叫的資源名」(例 Workers AI 的模型 id) + const target = recipe.endpoint; + const payload = renderBodyTemplate(recipe.body_template ?? recipe.body, ctxObj) + ?? Object.fromEntries(Object.entries(ctxObj).filter(([k]) => !k.startsWith('_'))); + + try { + const runner = binding as { run?: (model: string, input: unknown) => Promise }; + if (typeof runner.run !== 'function') { + return { + success: false, + error: `binding "${name}" 沒有 run() 方法,目前 binding 型只支援 run(model, input) 形狀(如 env.AI)。`, + }; + } + const data = await runner.run(target, payload); + if (recipe.response_map) { + const normalized = applyResponseMap(data, recipe.response_map); + return { success: true, data, text: normalized.text }; + } + return { success: true, data }; + } catch (e) { + return { + success: false, + error: `binding "${name}" 呼叫失敗(${target}):${e instanceof Error ? e.message : String(e)}`, + }; + } + }; +} + function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): ComponentRunner { return async (ctx: unknown) => { const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record : {}; @@ -293,9 +361,12 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): headers[k] = interpolate(v); } - // body:把 recipe.body 裡的 {{key}} 都換掉 + // body:優先 body_template(③ payload 層,3.12——支援巢狀/dot path/保留型別), + // 其次既有 recipe.body(淺層 {{key}},舊 recipe 照舊),最後才拿 ctx 當 body。 let bodyStr: string | undefined; - if (recipe.body) { + if (recipe.body_template) { + bodyStr = JSON.stringify(renderBodyTemplate(recipe.body_template, ctxObj)); + } else if (recipe.body) { bodyStr = interpolate(JSON.stringify(recipe.body)); } else if (method !== 'GET') { // 沒指定 body template → 用 ctx 當 body,但剔除 _ 前綴的內部欄位 @@ -313,6 +384,13 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): }); const data = await readBodyOnce(res); + + // ③ 回應正規化(3.12):未設 response_map ⇒ 原樣回傳(既有 recipe 零行為變化)。 + // 設了 ⇒ 額外附 `text`(各家形狀差異收在 recipe 裡,換源不必改 workflow)。 + if (recipe.response_map) { + const normalized = applyResponseMap(data, recipe.response_map); + return { success: res.ok, status: res.status, data, text: normalized.text }; + } return { success: res.ok, status: res.status, data }; }; } diff --git a/cypher-executor/src/lib/recipe-payload.ts b/cypher-executor/src/lib/recipe-payload.ts new file mode 100644 index 0000000..32631cb --- /dev/null +++ b/cypher-executor/src/lib/recipe-payload.ts @@ -0,0 +1,154 @@ +/** + * recipe 的 payload 與回應處理層(SDD workflow-discovery 3.12 / CP arcrun-usable 步驟 5 缺口②) + * + * 為什麼存在(leo 的三層模型,第③層過去是空的): + * ① 零件(http_request) ② auth recipe(auth_service) ③ **payload recipe** ← 這層 + * 舊 schema 存不住 body 與「回應怎麼取值」⇒ 帶 body 的 API 只能把整包寫進 workflow code, + * 回應解析(rag_chat 的 finalize,2786 字元)綁死 Gemini 格式 ⇒ 換源必壞。 + * 有了這層:**換 LLM 供應商=換 recipe,不必動 workflow**。 + * + * 相容鐵律:三個欄位全為選填。既有 recipe(沒有這些欄位)行為**完全不變**—— + * renderBodyTemplate(undefined,…) 回 undefined、applyResponseMap(body, undefined) 原樣回傳。 + */ + +/** 回應正規化規則(隨 recipe 走,故換源=換 recipe) */ +export type ResponseMap = { + /** + * 取值路徑(dot path,支援陣列索引)。 + * 例:Gemini `candidates.0.content.parts.0.text`/Claude `content.0.text`/ + * Workers AI `response`。 + * 搭配 thinking_model 時可指向 parts 陣列本身。 + */ + text_path?: string; + /** + * 思考型模型(如 gemma):parts 內會混入 `thought: true` 的思考過程, + * 要剔除後取最後一個非 thought 的 part。 + */ + thinking_model?: boolean; + /** 淨化:要剝掉的前綴(實撞過「Draft:」「*」「Answer:」,且組合順序不定) */ + strip_prefixes?: string[]; + /** 答案標記:出現時只取其後的內容(實撞:模型會把草稿吐在標記前) */ + answer_marker?: string; +}; + +/** 從物件用 dot path 取值:'a.0.b' → obj.a[0].b */ +function getPath(obj: unknown, path: string): unknown { + let cur: unknown = obj; + for (const part of path.split('.')) { + if (cur === null || cur === undefined) return undefined; + if (typeof cur !== 'object') return undefined; + cur = (cur as Record)[part]; + } + return cur; +} + +// ── ③-a body_template:payload 收回 recipe ─────────────────────────────────── + +/** + * 把 body_template 內所有 `{{var}}` 用 ctx 填掉(遞迴進巢狀 object / array)。 + * + * 與 graph-executor 的 interpolateData 同一套語義(刻意一致,避免兩種插值行為): + * - 整個字串就是單一 `{{x}}` → 回**原型別**(陣列/物件/數字不被 stringify) + * - 混合文字 → 拼成字串 + * - 取不到 → **保留原樣** `{{x}}`(看得見才好 debug,不靜默吞掉) + */ +export function renderBodyTemplate( + template: unknown, + ctx: Record, +): unknown { + if (template === undefined || template === null) return undefined; + return renderValue(template, ctx); +} + +function renderValue(v: unknown, ctx: Record): unknown { + if (typeof v === 'string') return renderString(v, ctx); + if (Array.isArray(v)) return v.map(item => renderValue(item, ctx)); + if (v !== null && typeof v === 'object') { + const out: Record = {}; + for (const [k, val] of Object.entries(v as Record)) { + out[k] = renderValue(val, ctx); + } + return out; + } + return v; +} + +function renderString(s: string, ctx: Record): unknown { + const single = s.match(/^\s*\{\{([\w.]+)\}\}\s*$/); + if (single) { + const val = getPath(ctx, single[1]); + return val === undefined ? s : val; + } + return s.replace(/\{\{([\w.]+)\}\}/g, (_, key: string) => { + const val = getPath(ctx, key); + if (val === undefined) return `{{${key}}}`; + return typeof val === 'string' ? val : JSON.stringify(val); + }); +} + +// ── ③-b response_map:回應正規化 ───────────────────────────────────────────── + +export type NormalizedResponse = { + /** 正規化後的純文字(沒有 response_map 或取不到時 undefined——誠實,不編造) */ + text?: string; + /** 原始回應永遠保留(除錯與向後相容都靠它) */ + raw: unknown; +}; + +/** + * 依 response_map 把各家 API 的回應正規化成 `{ text }`。 + * 沒給 map ⇒ 原樣回傳(既有 recipe 零行為變化)。 + */ +export function applyResponseMap(body: unknown, map?: ResponseMap): NormalizedResponse { + if (!map) return { raw: body }; + + let picked: unknown = map.text_path ? getPath(body, map.text_path) : body; + + // 思考型模型:picked 是 parts 陣列 → 剔除 thought=true,取最後一個 + if (map.thinking_model && Array.isArray(picked)) { + const real = picked.filter( + p => !(p && typeof p === 'object' && (p as Record).thought === true), + ); + const last = real[real.length - 1]; + picked = (last && typeof last === 'object') + ? (last as Record).text + : last; + } + + if (typeof picked !== 'string') return { text: undefined, raw: body }; + + return { text: sanitize(picked, map), raw: body }; +} + +/** + * 淨化(知識是實撞出來的,非預想): + * 1. 有 answer_marker → 只取標記**最後一次**出現之後的內容 + * (實撞:模型的自檢清單內文也會提到標記,用 lastIndexOf 才撈得到真的那個) + * 2. 前綴組合順序不定(「* 【答】」「Draft: 【答】」「Answer: * 【答】」三型都撞過) + * ⇒ **循環**剝殼,單趟剝不乾淨 + */ +function sanitize(input: string, map: ResponseMap): string { + let s = input.trim(); + + if (map.answer_marker) { + const idx = s.lastIndexOf(map.answer_marker); + if (idx >= 0) s = s.slice(idx + map.answer_marker.length); + } + + const prefixes = map.strip_prefixes ?? []; + if (prefixes.length > 0) { + let changed = true; + while (changed) { + changed = false; + s = s.trimStart(); + for (const p of prefixes) { + if (p && s.startsWith(p)) { + s = s.slice(p.length); + changed = true; + } + } + } + } + + return s.trim(); +} diff --git a/cypher-executor/src/routes/recipes.ts b/cypher-executor/src/routes/recipes.ts index 402b45e..a5aa25b 100644 --- a/cypher-executor/src/routes/recipes.ts +++ b/cypher-executor/src/routes/recipes.ts @@ -16,6 +16,7 @@ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { deriveRecipeHash } from '../lib/hash'; +import type { ResponseMap } from '../lib/recipe-payload'; export const recipesRouter = new Hono<{ Bindings: Bindings }>(); @@ -34,6 +35,26 @@ export interface RecipeDefinition { method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST headers?: Record; body?: Record; + /** + * ③ payload 層(SDD workflow-discovery 3.12):帶 body 的 API 把 payload 收回 recipe, + * 不必寫進 workflow code。與 `body` 的差別=支援巢狀 {{var}} 與 dot path、 + * 單一引用保留原型別。兩者並存時 body_template 優先(新欄位贏,舊 recipe 不受影響)。 + */ + body_template?: Record; + /** + * ③ 回應正規化層:各家 API 回應形狀不同(Gemini/Claude/Workers AI), + * 取值路徑・思考型模型旗標・淨化規則**隨 recipe 走** ⇒ 換源=換 recipe,不必改 workflow。 + * 未設=原樣回傳(既有 recipe 行為零變化)。 + */ + response_map?: ResponseMap; + /** + * 認證型別。未設=沿用既有 auth_service 判斷(向後相容)。 + * `binding`=**免金鑰**,用平台內建能力(env.AI/VECTORIZE/BROWSER/QUEUE), + * 不是為 Workers AI 開特例——Cloudflare 這一整類都被舊抽象(只認 HTTP+金鑰)排除在外。 + */ + auth?: 'static_key' | 'service_account' | 'oauth2' | 'binding'; + /** auth='binding' 時指定用哪個 binding(例 'AI'/'VECTORIZE')。 */ + binding_name?: string; /** * 此 recipe 要用哪個 auth recipe(auth_recipe:{auth_service})。 * 讓多個 recipe 共用同一把 auth(例:kbdb_get / kbdb_create_block 都設 "kbdb")。 @@ -116,6 +137,11 @@ recipesRouter.post('/recipes', async (c) => { method: (body.method ?? 'POST').toUpperCase(), headers: body.headers, body: body.body, + // ③ payload/回應/binding 三層(3.12):全選填,沒給就是 undefined=既有行為 + body_template: body.body_template, + response_map: body.response_map, + auth: body.auth, + binding_name: body.binding_name, auth_service: body.auth_service, credentials_required: body.credentials_required, created_at: existing?.created_at ?? now, diff --git a/cypher-executor/tests/recipe-payload-response.test.ts b/cypher-executor/tests/recipe-payload-response.test.ts new file mode 100644 index 0000000..b9d5661 --- /dev/null +++ b/cypher-executor/tests/recipe-payload-response.test.ts @@ -0,0 +1,123 @@ +/** + * recipe payload 與回應處理層 —— CP `arcrun-usable` 步驟 5 缺口② + * SDD: workflow-discovery task 3.12 + * + * 為什麼要有這三層(別刪): + * 舊 schema 只有 {canonical_id, endpoint, method, auth_service}(body 有但淺) + * ⇒ 帶 body 的 API 只能繞過 recipe 把整包寫進 workflow code; + * 回應解析(rag_chat 的 finalize,2786 字元)綁死 Gemini 格式,換源必壞。 + * leo:三層模型=①零件 ②auth recipe ③payload recipe,第③層過去不存在。 + * + * 本檔測純函式層(body_template 插值 / response_map 正規化), + * 不打真外部 API——外部呼叫由 stage 端到端驗(features/09)。 + */ +import { describe, it, expect } from 'vitest'; +import { renderBodyTemplate, applyResponseMap } from '../src/lib/recipe-payload'; + +describe('body_template:payload 收回 recipe(第③層)', () => { + it('巢狀結構的 {{var}} 都會被替換(不只 top-level)', () => { + const out = renderBodyTemplate( + { contents: [{ parts: [{ text: '{{prompt}}' }] }] }, + { prompt: '你好' }, + ); + expect(out).toEqual({ contents: [{ parts: [{ text: '你好' }] }] }); + }); + + it('單一引用保留原型別(陣列/物件不被 stringify)', () => { + const out = renderBodyTemplate( + { messages: '{{history}}', n: '{{count}}' }, + { history: [{ role: 'user' }], count: 3 }, + ) as Record; + expect(out.messages).toEqual([{ role: 'user' }]); + expect(out.n).toBe(3); + }); + + it('混合文字仍拼成字串', () => { + const out = renderBodyTemplate({ q: '請回答:{{prompt}}' }, { prompt: '天氣' }) as Record; + expect(out.q).toBe('請回答:天氣'); + }); + + it('支援 dot path 取值', () => { + const out = renderBodyTemplate({ t: '{{assemble.data.prompt}}' }, { + assemble: { data: { prompt: '深層值' } }, + }) as Record; + expect(out.t).toBe('深層值'); + }); + + it('取不到的變數保留原樣(不靜默變 undefined,看得見才好 debug)', () => { + const out = renderBodyTemplate({ t: '{{nope}}' }, {}) as Record; + expect(out.t).toBe('{{nope}}'); + }); + + it('沒有 body_template → 回 undefined(呼叫端沿用既有行為)', () => { + expect(renderBodyTemplate(undefined, { a: 1 })).toBeUndefined(); + }); +}); + +describe('response_map:回應正規化(換源不必改 workflow)', () => { + const geminiBody = { + candidates: [{ content: { parts: [{ text: '【答】台北是首都' }] } }], + }; + + it('path 取值:Gemini 形狀 → 純文字', () => { + const out = applyResponseMap(geminiBody, { text_path: 'candidates.0.content.parts.0.text' }); + expect(out.text).toBe('【答】台北是首都'); + }); + + it('換源=換 recipe:Claude 形狀用不同 path,同樣取得出文字', () => { + const claudeBody = { content: [{ type: 'text', text: 'Claude 的答案' }] }; + const out = applyResponseMap(claudeBody, { text_path: 'content.0.text' }); + expect(out.text).toBe('Claude 的答案'); + }); + + it('Workers AI 形狀(binding 回傳)同樣走 path', () => { + const waiBody = { response: 'Workers AI 的答案' }; + const out = applyResponseMap(waiBody, { text_path: 'response' }); + expect(out.text).toBe('Workers AI 的答案'); + }); + + it('思考型模型:thought=true 的 part 要被剔除,取最後一個非 thought', () => { + const gemma = { + candidates: [{ + content: { + parts: [ + { text: '讓我想想…', thought: true }, + { text: '真正的答案' }, + ], + }, + }], + }; + const out = applyResponseMap(gemma, { + text_path: 'candidates.0.content.parts', + thinking_model: true, + }); + expect(out.text).toBe('真正的答案'); + }); + + it('淨化規則:剝掉【答】前的草稿前綴(實撞三型之一)', () => { + const out = applyResponseMap( + { r: 'Draft: 【答】正確內容' }, + { text_path: 'r', strip_prefixes: ['Draft:', '*', 'Answer:'], answer_marker: '【答】' }, + ); + expect(out.text).toBe('正確內容'); + }); + + it('淨化規則:前綴組合順序不定 → 循環剝殼剝乾淨', () => { + const out = applyResponseMap( + { r: 'Answer: * 【答】內容' }, + { text_path: 'r', strip_prefixes: ['Draft:', '*', 'Answer:'], answer_marker: '【答】' }, + ); + expect(out.text).toBe('內容'); + }); + + it('沒有 response_map → 原樣回傳(既有 recipe 行為完全不變)', () => { + const out = applyResponseMap(geminiBody, undefined); + expect(out.text).toBeUndefined(); + expect(out.raw).toEqual(geminiBody); + }); + + it('path 取不到 → 誠實回 undefined,不編造', () => { + const out = applyResponseMap({ a: 1 }, { text_path: 'b.c.d' }); + expect(out.text).toBeUndefined(); + }); +});