步驟5 驗收與 SDD 落帳:判斷骨架重寫實測 1201→350 字元、if×8→0(降 71%)

tests/step5-acceptance.test.ts(3 項綠):
- 多路分流+失敗路三條各自到位、全程零 code 節點、success=true
- 布林兩路(if_control)同樣零 code
- 字元數對照:舊寫法(判斷全塞進一個 code 節點的 JS)1201 字元 if×8
  → 新寫法(分支邊+recipe response_map 宣告)350 字元 if×0

誠實標記(寫進測試檔頭與 tasks,不讓後人誤讀):線上那顆 assemble(5509 字元 if×23)
住在 arcrun-rag 實例、本 repo 無其定義 ⇒ 本次是把它的**判斷骨架**以新能力重建成等價
工作流,證明判斷不必寫在 JS 裡,**不是**直接改寫線上節點。真正改寫與 haiku 逐顆查場景
=stage 端到端(features/09)才算數,故 3.13 標 ◐ 不標 。

tasks.md:3.11/3.12 標 [x] 附證據,3.13 標 [◐] 附待辦界線。
全套 212 passed(基線 179 +33 新),失敗數維持既有 9 筆未變;tsc --noEmit 綠。

SDD: workflow-discovery 3.11/3.12/3.13|CP: arcrun-usable 步驟 5

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-31 16:34:20 +08:00
parent 5f5c0a89e2
commit c9feb15a37
2 changed files with 206 additions and 3 deletions
@@ -0,0 +1,170 @@
/**
* 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 的 assemble5509 字元、if×23、for×12
*/
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
async function execute(graph: unknown, context: Record<string, unknown> = {}) {
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<string, unknown>;
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);
});
});