Files
Arcrun/cypher-executor/src/lib/recipe-payload.ts
T
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

155 lines
5.9 KiB
TypeScript
Raw 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 與回應處理層(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();
}