Files
Arcrun/cypher-executor/tests/recipe-payload-response.test.ts
uncle6me-web 5f5c0a89e2 步驟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>
2026-07-31 16:31:58 +08:00

124 lines
4.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
});
});