/** * 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)[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, ): unknown { if (template === undefined || template === null) return undefined; return renderValue(template, ctx); } function renderValue(v: unknown, ctx: Record): 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 = {}; for (const [k, val] of Object.entries(v as Record)) { out[k] = renderValue(val, ctx); } return out; } return v; } function renderString(s: string, ctx: Record): 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).thought === true), ); const last = real[real.length - 1]; picked = (last && typeof last === 'object') ? (last as Record).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(); }