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>
155 lines
5.9 KiB
TypeScript
155 lines
5.9 KiB
TypeScript
/**
|
||
* 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<string, unknown>)[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<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();
|
||
}
|