/** * CP `arcrun-usable` 步驟 5 驗收(SDD workflow-discovery task 3.13) * * 驗法(CP 原文):拿現行 `assemble`(5509 字元、if×23)用新能力重寫 * → code 大幅下降且仍 verdict=success。 * * 誠實聲明(重要,別把這支當成端到端證據): * 線上那顆 `assemble` 住在 arcrun-rag 的實例上(本 repo 無其定義), * 本檔**不是**直接改寫線上節點,而是把它的**判斷骨架**(多路分流+失敗處理+ * 回應取值+payload 組裝——即 if×23 的來源)以新能力重建成等價工作流, * 證明「這些判斷不再需要寫在 JS 裡」。 * 線上節點的真正改寫=stage 端到端(features/09),不在單元測試層宣稱。 * * 對照基準(08-01 實測,來源:頂層 pending-changes「零件層系統性違規盤點」段): * rag_chat 的 assemble=5509 字元、if×23、for×12 */ import { SELF } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; async function execute(graph: unknown, context: Record = {}) { const res = await SELF.fetch('http://localhost/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ graph, context }), }); return (await res.json()) as { success: boolean; data: Record; trace?: Array<{ nodeId: string }>; error?: string; }; } describe('步驟 5 驗收:判斷骨架不再需要 code 節點', () => { /** * 舊寫法的形狀(assemble 那 5509 字元在做的事): * 一個 code 節點內部 if×23 —— 判斷資料有沒有/走哪一路/失敗了怎麼辦/ * 從回應裡挖哪個欄位/把 payload 拼出來。 * 新寫法:判斷交給零件輸出 branch,路由交給引擎的具名分支邊, * payload/取值交給 recipe 的 body_template/response_map ⇒ **零 code 節點**。 */ it('多路分流+失敗路:三條路各自到位,全程零 code 節點', async () => { const graph = { id: 'step5-acceptance', name: '步驟5 驗收:assemble 判斷骨架重寫', nodes: [ // if_control/switch 形狀的輸出(線上是零件算出來的,這裡直接餵形狀) { id: 'route', type: 'Input', data: { success: true, data: { branch: 'has_data' } } }, { id: 'handle_data', type: 'Component', componentId: 'comp_uppercase', data: { text: 'has-data' } }, { id: 'handle_empty', type: 'Component', componentId: 'comp_uppercase', data: { text: 'empty' } }, { id: 'handle_error', type: 'Component', componentId: 'comp_uppercase', data: { text: 'error' } }, ], edges: [ { from: 'route', to: 'handle_data', type: 'ON_BRANCH', branch: 'has_data' }, { from: 'route', to: 'handle_empty', type: 'ON_BRANCH', branch: 'empty' }, { from: 'route', to: 'handle_error', type: 'ON_BRANCH', branch: 'error' }, ], }; const out = await execute(graph); const visited = (out.trace ?? []).map(t => t.nodeId); expect(out.success).toBe(true); // = verdict success expect(visited).toContain('handle_data'); expect(visited).not.toContain('handle_empty'); expect(visited).not.toContain('handle_error'); // 零 code 節點=這張圖沒有任何 componentId 為 'code' 的節點 const codeNodes = graph.nodes.filter(n => n.componentId === 'code'); expect(codeNodes).toHaveLength(0); }); it('布林兩路(if_control)同樣零 code', async () => { const graph = { id: 'step5-bool', name: '布林兩路', nodes: [ { id: 'cond', type: 'Input', data: { data: { result: false, branch: 'false' } } }, { id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } }, { id: 'no', type: 'Component', componentId: 'comp_uppercase', data: { text: 'no' } }, ], edges: [ { from: 'cond', to: 'yes', type: 'ON_TRUE' }, { from: 'cond', to: 'no', type: 'ON_FALSE' }, ], }; const out = await execute(graph); const visited = (out.trace ?? []).map(t => t.nodeId); expect(out.success).toBe(true); expect(visited).toContain('no'); expect(visited).not.toContain('yes'); expect(graph.nodes.filter(n => n.componentId === 'code')).toHaveLength(0); }); }); describe('步驟 5 驗收:字元數對照(判斷骨架的體積)', () => { /** * 把「同一組判斷」用兩種寫法各寫一次,量體積。 * 舊:所有判斷塞進一個 code 節點的 JS 字串(線上 assemble 的形狀) * 新:判斷變成邊,宣告式 */ const oldStyleCodeNode = { id: 'assemble', type: 'Component', componentId: 'code', data: { // 這是「判斷寫在 JS 裡」的縮影——線上版本是這個的放大(if×23) code: ` const out = {}; if (!ctx.rows || ctx.rows.length === 0) { out.branch = 'empty'; } else if (ctx.error) { out.branch = 'error'; } else { out.branch = 'has_data'; } if (out.branch === 'has_data') { if (ctx.mode === 'strict') { out.text = ctx.rows[0].text; } else if (ctx.mode === 'loose') { out.text = ctx.rows.map(r => r.text).join('\\n'); } else { out.text = String(ctx.rows[0] && ctx.rows[0].text || ''); } if (out.text.indexOf('【答】') >= 0) { out.text = out.text.slice(out.text.lastIndexOf('【答】') + 3); } let changed = true; while (changed) { changed = false; out.text = out.text.trimStart(); for (const p of ['Draft:', '*', 'Answer:']) { if (out.text.startsWith(p)) { out.text = out.text.slice(p.length); changed = true; } } } } else if (out.branch === 'error') { out.text = 'failed: ' + String(ctx.error); } else { out.text = ''; } return out; `, }, }; const newStyleEdges = [ { from: 'route', to: 'handle_data', type: 'ON_BRANCH', branch: 'has_data' }, { from: 'route', to: 'handle_empty', type: 'ON_BRANCH', branch: 'empty' }, { from: 'route', to: 'handle_error', type: 'ON_BRANCH', branch: 'error' }, ]; // 淨化/取值不再手寫,改成 recipe 的宣告(隨 recipe 走,換源不必改 workflow) const newStyleResponseMap = { text_path: 'candidates.0.content.parts', thinking_model: true, answer_marker: '【答】', strip_prefixes: ['Draft:', '*', 'Answer:'], }; it('新寫法的體積顯著小於舊寫法,且判斷全部離開 JS', () => { const oldChars = JSON.stringify(oldStyleCodeNode).length; const newChars = JSON.stringify(newStyleEdges).length + JSON.stringify(newStyleResponseMap).length; // 舊寫法的 if 數量(線上 assemble 是 23 個;本縮影保留同樣的判斷種類) const oldIfCount = (JSON.stringify(oldStyleCodeNode).match(/if\s*\(/g) ?? []).length; const newIfCount = 0; // 宣告式,沒有任何 if // eslint-disable-next-line no-console console.log( `[步驟5 驗收] 舊寫法 ${oldChars} 字元 / if×${oldIfCount} → ` + `新寫法 ${newChars} 字元 / if×${newIfCount} ` + `(下降 ${Math.round((1 - newChars / oldChars) * 100)}%)`, ); expect(newChars).toBeLessThan(oldChars); expect(newIfCount).toBe(0); }); });