5f5c0a89e2
問題:舊 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>
124 lines
4.8 KiB
TypeScript
124 lines
4.8 KiB
TypeScript
/**
|
||
* 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<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('換源=換 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();
|
||
});
|
||
});
|