步驟5 缺口②:recipe 補 payload/回應正規化/binding 三層(leo 三層模型的第③層)

問題:舊 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 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-31 16:31:58 +08:00
parent 323ccc8475
commit 5f5c0a89e2
6 changed files with 441 additions and 6 deletions
@@ -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):兩庫(零件 registryrecipe 庫)都查過才點名,
* 並告訴 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:逐顆查 recipen8n 式)時,光看 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 = {
+7 -2
View File
@@ -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<string>();
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 {
+82 -4
View File
@@ -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 runnercanonical_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_IDShttp_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 runner3.12 第四型認證):不打外部 HTTP、不需要任何金鑰,
* 直接用平台 bindingenv.AIVECTORIZE/…)⇒ leo 要的「開機就可用」。
*
* 為什麼要開這型:recipe 的舊抽象=「打一個外部 HTTP API」(endpoint+method+auth_service),
* 而 Cloudflare 的 binding 呼叫不是 HTTP ⇒ **整類能力被排除在 recipe 之外**。
* 開這一型不是為 Workers AI 開特例,是一次打開 env.AIVECTORIZEBROWSERQUEUE 整排。
*/
function makeBindingRecipeRunner(
recipe: import('../routes/recipes').RecipeDefinition,
env: Bindings,
): ComponentRunner {
return async (ctx: unknown) => {
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
const name = recipe.binding_name ?? 'AI';
const binding = (env as unknown as Record<string, unknown>)[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<unknown> };
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<string, unknown> : {};
@@ -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 };
};
}
+154
View File
@@ -0,0 +1,154 @@
/**
* recipe 的 payload 與回應處理層(SDD workflow-discovery 3.12 / CP arcrun-usable 步驟 5 缺口②)
*
* 為什麼存在(leo 的三層模型,第③層過去是空的):
* ① 零件(http_request ② auth recipeauth_service ③ **payload recipe** ← 這層
* 舊 schema 存不住 body 與「回應怎麼取值」⇒ 帶 body 的 API 只能把整包寫進 workflow code
* 回應解析(rag_chat 的 finalize2786 字元)綁死 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<string, unknown>)[part];
}
return cur;
}
// ── ③-a body_templatepayload 收回 recipe ───────────────────────────────────
/**
* 把 body_template 內所有 `{{var}}` 用 ctx 填掉(遞迴進巢狀 object / array)。
*
* 與 graph-executor 的 interpolateData 同一套語義(刻意一致,避免兩種插值行為):
* - 整個字串就是單一 `{{x}}` → 回**原型別**(陣列/物件/數字不被 stringify
* - 混合文字 → 拼成字串
* - 取不到 → **保留原樣** `{{x}}`(看得見才好 debug,不靜默吞掉)
*/
export function renderBodyTemplate(
template: unknown,
ctx: Record<string, unknown>,
): unknown {
if (template === undefined || template === null) return undefined;
return renderValue(template, ctx);
}
function renderValue(v: unknown, ctx: Record<string, unknown>): 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<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
out[k] = renderValue(val, ctx);
}
return out;
}
return v;
}
function renderString(s: string, ctx: Record<string, unknown>): 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<string, unknown>).thought === true),
);
const last = real[real.length - 1];
picked = (last && typeof last === 'object')
? (last as Record<string, unknown>).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();
}
+26
View File
@@ -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<string, string>;
body?: Record<string, unknown>;
/**
* ③ payload 層(SDD workflow-discovery 3.12):帶 body 的 API 把 payload 收回 recipe
* 不必寫進 workflow code。與 `body` 的差別=支援巢狀 {{var}} 與 dot path、
* 單一引用保留原型別。兩者並存時 body_template 優先(新欄位贏,舊 recipe 不受影響)。
*/
body_template?: Record<string, unknown>;
/**
* ③ 回應正規化層:各家 API 回應形狀不同(GeminiClaudeWorkers AI),
* 取值路徑・思考型模型旗標・淨化規則**隨 recipe 走** ⇒ 換源=換 recipe,不必改 workflow。
* 未設=原樣回傳(既有 recipe 行為零變化)。
*/
response_map?: ResponseMap;
/**
* 認證型別。未設=沿用既有 auth_service 判斷(向後相容)。
* `binding`**免金鑰**,用平台內建能力(env.AIVECTORIZEBROWSERQUEUE),
* 不是為 Workers AI 開特例——Cloudflare 這一整類都被舊抽象(只認 HTTP+金鑰)排除在外。
*/
auth?: 'static_key' | 'service_account' | 'oauth2' | 'binding';
/** auth='binding' 時指定用哪個 binding(例 'AI''VECTORIZE')。 */
binding_name?: string;
/**
* 此 recipe 要用哪個 auth recipeauth_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,
@@ -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 finalize2786 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_templatepayload 收回 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<string, unknown>;
expect(out.messages).toEqual([{ role: 'user' }]);
expect(out.n).toBe(3);
});
it('混合文字仍拼成字串', () => {
const out = renderBodyTemplate({ q: '請回答:{{prompt}}' }, { prompt: '天氣' }) as Record<string, unknown>;
expect(out.q).toBe('請回答:天氣');
});
it('支援 dot path 取值', () => {
const out = renderBodyTemplate({ t: '{{assemble.data.prompt}}' }, {
assemble: { data: { prompt: '深層值' } },
}) as Record<string, unknown>;
expect(out.t).toBe('深層值');
});
it('取不到的變數保留原樣(不靜默變 undefined,看得見才好 debug', () => {
const out = renderBodyTemplate({ t: '{{nope}}' }, {}) as Record<string, unknown>;
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('換源=換 recipeClaude 形狀用不同 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();
});
});