合流 batch:步驟 3/4/5/6 併成一版出貨(同一顆 cypher worker,bundle 是 worker 粒度)
鐵律(08-01 血淚):CP 可分步驗收,但 bundle 是 worker 粒度——凡改同一顆 worker 的多步, 出貨前必先合流成一版再重生 bundle,否則 stage 只拿到一步(三條分支各自建 bundle 的坑)。 base=feat/step3-missing-guidance(最前緣,已含 step4+step6+t158-t162 prod 修) merge=feat/step5-conditional-edges(引擎條件邊+recipe 三層+教材世代修)
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 逐顆查詢的回應要自我說明分支用法(SDD workflow-discovery 3.11;總管 08-01 抽驗第 3 點)
|
||||
*
|
||||
* 判準(leo/總管一致):**AI 只看那一顆的回應,就知道怎麼接下一步**——
|
||||
* 不必回頭讀 skill、不必猜。看得到分支說明才算數。
|
||||
*
|
||||
* 取證背景(08-01 prod):逐顆查 if_control 只回
|
||||
* status/componentId/type/source/input_schema{condition,input}/success_rate/stability
|
||||
* ⇒ **沒有任何欄位說明分支怎麼接** ⇒ 走 n8n 式逐顆查的 AI 只好寫 code。
|
||||
*
|
||||
* 本檔直接驗 `branchHintFor()`(回應裡那個欄位的來源),並把 AI 實際會看到的內容印出來。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { branchHintFor } from '../src/lib/branch-hints';
|
||||
|
||||
describe('三顆分支零件的查詢回應自帶用法(AI 看一眼就知道怎麼接)', () => {
|
||||
for (const id of ['if_control', 'switch', 'try_catch']) {
|
||||
it(`${id}:回應含 branch_field/branches/edge_types/usage/example`, () => {
|
||||
const hint = branchHintFor(id);
|
||||
expect(hint).toBeDefined();
|
||||
|
||||
// 這一顆會輸出哪個欄位當分支標籤
|
||||
expect(hint!.branch_field).toBe('data.branch');
|
||||
// 接下游要用哪些邊型
|
||||
expect(hint!.edge_types.length).toBeGreaterThan(0);
|
||||
// 一行說明 + 可照抄範例(缺任一個,AI 都得自己猜)
|
||||
expect(hint!.usage.length).toBeGreaterThan(0);
|
||||
expect(hint!.example.length).toBeGreaterThan(0);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`\n──────── 逐顆查 ${id} 時,AI 會看到的 branch_hint ────────\n` +
|
||||
JSON.stringify(hint, null, 2),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('if_control 明說 ON_TRUE/ON_FALSE 兩條邊', () => {
|
||||
const h = branchHintFor('if_control')!;
|
||||
expect(h.edge_types).toContain('ON_TRUE');
|
||||
expect(h.edge_types).toContain('ON_FALSE');
|
||||
expect(h.branches).toEqual(['true', 'false']);
|
||||
// 明說「不需要自己寫 code 判斷」——這句是防腹語術的關鍵
|
||||
expect(h.usage).toContain('不需要自己寫 code');
|
||||
});
|
||||
|
||||
it('switch 明說用 ON_BRANCH 並在邊上標 case 名,且 default 不需特別邊型', () => {
|
||||
const h = branchHintFor('switch')!;
|
||||
expect(h.edge_types).toContain('ON_BRANCH');
|
||||
expect(h.usage).toContain('ON_BRANCH');
|
||||
expect(h.usage).toContain('default_branch');
|
||||
// branches 是動態的(由 cases 決定),要誠實說明而非給死清單
|
||||
expect(typeof h.branches).toBe('string');
|
||||
});
|
||||
|
||||
it('try_catch 明說 try/catch 兩條標籤,錯誤處理不必寫 code', () => {
|
||||
const h = branchHintFor('try_catch')!;
|
||||
expect(h.branches).toEqual(['try', 'catch']);
|
||||
expect(h.edge_types).toContain('ON_BRANCH');
|
||||
expect(h.usage).toContain('不需要寫 code');
|
||||
});
|
||||
|
||||
it('不分岔的零件沒有 branch_hint(不加噪音)', () => {
|
||||
expect(branchHintFor('http_request')).toBeUndefined();
|
||||
expect(branchHintFor('code')).toBeUndefined();
|
||||
expect(branchHintFor(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 三型分支零件「真的接上引擎」的實測(SDD workflow-discovery 3.11)
|
||||
*
|
||||
* 為什麼要另立這一支(總管 08-01 抽驗要求,正確的要求):
|
||||
* conditional-edges.test.ts 是用 Input 節點**手餵分支形狀**測引擎走邊邏輯,
|
||||
* 那證明的是「引擎依標籤選邊」,**沒有證明「真零件吐出來的標籤真的對得上」**。
|
||||
* leo 特別點名 switch/try_catch,且 `ON_CASE`/`ON_CATCH` grep=0
|
||||
* ⇒ 必須排除「機制通用所以理論上支援」這種推論。
|
||||
*
|
||||
* 本檔的 given 全部是**真 WASM 零件的實跑輸出**(wasmtime 執行 .component-builds/*.wasm
|
||||
* 抓回來的原文,非杜撰),再送進引擎驗證走對邊。
|
||||
*
|
||||
* 真零件實跑指令(可復驗):
|
||||
* cd .component-builds
|
||||
* echo '{"condition":"status == active","input":{"status":"active"}}' | wasmtime if_control/component.wasm
|
||||
* echo '{"value":"pending","cases":[...],"default_branch":"branch_default"}' | wasmtime switch/component.wasm
|
||||
* echo '{"result":null,"error":"boom"}' | wasmtime try_catch/component.wasm
|
||||
*/
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
async function run(graph: unknown) {
|
||||
const res = await SELF.fetch('http://localhost/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ graph, context: {} }),
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
success: boolean;
|
||||
trace?: Array<{ nodeId: string }>;
|
||||
};
|
||||
return { body, visited: (body.trace ?? []).map(t => t.nodeId) };
|
||||
}
|
||||
|
||||
/** 真零件輸出 → 當作上游節點的 output 餵進圖 */
|
||||
function graphWith(realOutput: unknown, edges: Array<Record<string, unknown>>, extraNodes: string[]) {
|
||||
return {
|
||||
id: 'real-branch',
|
||||
name: '真零件輸出走邊',
|
||||
nodes: [
|
||||
{ id: 'ctrl', type: 'Input', data: realOutput },
|
||||
...extraNodes.map(id => ({
|
||||
id, type: 'Component', componentId: 'comp_uppercase', data: { text: id },
|
||||
})),
|
||||
],
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
describe('if_control 真輸出 → 引擎走對邊', () => {
|
||||
// 真跑:echo '{"condition":"status == active","input":{"status":"active"}}' | wasmtime if_control/component.wasm
|
||||
const REAL_TRUE = { data: { branch: 'true', result: true }, success: true };
|
||||
// 真跑:input.status = "inactive"
|
||||
const REAL_FALSE = { data: { branch: 'false', result: false }, success: true };
|
||||
|
||||
const edges = [
|
||||
{ from: 'ctrl', to: 'yes', type: 'ON_TRUE' },
|
||||
{ from: 'ctrl', to: 'no', type: 'ON_FALSE' },
|
||||
];
|
||||
|
||||
it('條件成立(真輸出 branch="true")→ 走 ON_TRUE', async () => {
|
||||
const { body, visited } = await run(graphWith(REAL_TRUE, edges, ['yes', 'no']));
|
||||
expect(body.success).toBe(true);
|
||||
expect(visited).toContain('yes');
|
||||
expect(visited).not.toContain('no');
|
||||
});
|
||||
|
||||
it('條件不成立(真輸出 branch="false")→ 走 ON_FALSE', async () => {
|
||||
const { visited } = await run(graphWith(REAL_FALSE, edges, ['yes', 'no']));
|
||||
expect(visited).toContain('no');
|
||||
expect(visited).not.toContain('yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('switch 真輸出 → 引擎走對邊(多路+default,leo:「switch 更嚴重」)', () => {
|
||||
// 真跑(三個 case + default_branch):
|
||||
// value="active" → {"data":{"branch":"branch_active"},"success":true}
|
||||
// value="pending" → {"data":{"branch":"branch_pending"},"success":true}
|
||||
// value="zzz" → {"data":{"branch":"branch_default"},"success":true}
|
||||
const REAL_CASE1 = { data: { branch: 'branch_active' }, success: true };
|
||||
const REAL_CASE3 = { data: { branch: 'branch_pending' }, success: true };
|
||||
const REAL_DEFAULT = { data: { branch: 'branch_default' }, success: true };
|
||||
|
||||
const targets = ['p_active', 'p_inactive', 'p_pending', 'p_default'];
|
||||
const edges = [
|
||||
{ from: 'ctrl', to: 'p_active', type: 'ON_BRANCH', branch: 'branch_active' },
|
||||
{ from: 'ctrl', to: 'p_inactive', type: 'ON_BRANCH', branch: 'branch_inactive' },
|
||||
{ from: 'ctrl', to: 'p_pending', type: 'ON_BRANCH', branch: 'branch_pending' },
|
||||
{ from: 'ctrl', to: 'p_default', type: 'ON_BRANCH', branch: 'branch_default' },
|
||||
];
|
||||
|
||||
it('第 1 條 case(真輸出 branch_active)→ 只走 p_active', async () => {
|
||||
const { body, visited } = await run(graphWith(REAL_CASE1, edges, targets));
|
||||
expect(body.success).toBe(true);
|
||||
expect(visited).toContain('p_active');
|
||||
expect(visited).not.toContain('p_inactive');
|
||||
expect(visited).not.toContain('p_pending');
|
||||
expect(visited).not.toContain('p_default');
|
||||
});
|
||||
|
||||
it('第 3 條 case(真輸出 branch_pending)→ 只走 p_pending(證明第 N 條路走得對)', async () => {
|
||||
const { visited } = await run(graphWith(REAL_CASE3, edges, targets));
|
||||
expect(visited).toContain('p_pending');
|
||||
expect(visited).not.toContain('p_active');
|
||||
expect(visited).not.toContain('p_inactive');
|
||||
expect(visited).not.toContain('p_default');
|
||||
});
|
||||
|
||||
it('無匹配(真輸出 branch_default)→ 只走 p_default', async () => {
|
||||
const { visited } = await run(graphWith(REAL_DEFAULT, edges, targets));
|
||||
expect(visited).toContain('p_default');
|
||||
expect(visited).not.toContain('p_active');
|
||||
expect(visited).not.toContain('p_pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('try_catch 真輸出 → 引擎走對邊(ok/catch 兩路都驗)', () => {
|
||||
// 真跑:echo '{"result":{"value":42},"error":""}' | wasmtime try_catch/component.wasm
|
||||
const REAL_TRY = { data: { branch: 'try', result: { value: 42 } }, success: true };
|
||||
// 真跑:echo '{"result":null,"error":"boom"}' | wasmtime try_catch/component.wasm
|
||||
const REAL_CATCH = { data: { branch: 'catch', error: 'boom' }, success: true };
|
||||
|
||||
const edges = [
|
||||
{ from: 'ctrl', to: 'normal', type: 'ON_BRANCH', branch: 'try' },
|
||||
{ from: 'ctrl', to: 'rescue', type: 'ON_BRANCH', branch: 'catch' },
|
||||
];
|
||||
|
||||
it('成功(真輸出 branch="try")→ 走 normal,不走 rescue', async () => {
|
||||
const { body, visited } = await run(graphWith(REAL_TRY, edges, ['normal', 'rescue']));
|
||||
expect(body.success).toBe(true);
|
||||
expect(visited).toContain('normal');
|
||||
expect(visited).not.toContain('rescue');
|
||||
});
|
||||
|
||||
it('失敗(真輸出 branch="catch")→ 走 rescue,不走 normal', async () => {
|
||||
const { visited } = await run(graphWith(REAL_CATCH, edges, ['normal', 'rescue']));
|
||||
expect(visited).toContain('rescue');
|
||||
expect(visited).not.toContain('normal');
|
||||
});
|
||||
|
||||
it('try_catch 的 catch 路承接了「上游失敗」——不必寫 code try 一遍', async () => {
|
||||
// 這是 leo 點名 try_catch 的原因:schema 用文字寫「走 catch 分支」但機器層沒有那條路。
|
||||
// 現在有了:catch 標籤 → ON_BRANCH branch="catch" → 補救節點。
|
||||
const { visited } = await run(graphWith(REAL_CATCH, edges, ['normal', 'rescue']));
|
||||
expect(visited).toContain('rescue');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 條件邊 ON_TRUE / ON_FALSE / ON_BRANCH —— CP `arcrun-usable` 步驟 5 缺口①
|
||||
* SDD: workflow-discovery tasks 3.11
|
||||
*
|
||||
* 為什麼要有這組測試(別刪):
|
||||
* `if_control` 零件回 `{success, data:{result, branch}}`,但引擎過去只有
|
||||
* ON_SUCCESS / IF / FOREACH ⇒ 就算照規矩用 if_control,也只拿到布林值,
|
||||
* 還是得寫 code 判斷該走哪條路 ⇒ 這正是「全變成 code」的根(Arcrun#5)。
|
||||
*
|
||||
* 本檔先寫測試再改引擎(引擎核心風險最高,紅線要求)。
|
||||
* 既有邊行為的零變化迴歸另見 executor.test.ts(PIPE/IF/ON_SUCCESS 原樣通過)。
|
||||
*/
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/** 送一張圖進 /execute,回 parsed JSON */
|
||||
async function run(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 {
|
||||
status: res.status,
|
||||
body: (await res.json()) as {
|
||||
success: boolean;
|
||||
data: Record<string, unknown>;
|
||||
trace?: Array<{ nodeId: string }>;
|
||||
error?: string;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 Input 節點直接餵出 if_control 形狀的 output({data:{result,branch}}),
|
||||
* 避免測試依賴真的 WASM 零件(單元層只驗「引擎怎麼走邊」)。
|
||||
*/
|
||||
function branchGraph(branch: 'true' | 'false', edges: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
id: `g-branch-${branch}`,
|
||||
name: '條件邊測試',
|
||||
nodes: [
|
||||
// 模擬 if_control 的輸出形狀
|
||||
{ id: 'cond', type: 'Input', data: { success: true, data: { result: branch === 'true', branch } } },
|
||||
{ id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } },
|
||||
{ id: 'no', type: 'Component', componentId: 'comp_uppercase', data: { text: 'no' } },
|
||||
],
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
describe('條件邊:ON_TRUE / ON_FALSE(缺口① Arcrun#5 根治)', () => {
|
||||
it('branch=true → 只走 ON_TRUE 那條,ON_FALSE 那條不執行', async () => {
|
||||
const { body } = await run(
|
||||
branchGraph('true', [
|
||||
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||||
{ from: 'cond', to: 'no', type: 'ON_FALSE' },
|
||||
]),
|
||||
);
|
||||
expect(body.success).toBe(true);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('yes');
|
||||
expect(visited).not.toContain('no');
|
||||
});
|
||||
|
||||
it('branch=false → 只走 ON_FALSE 那條,ON_TRUE 那條不執行', async () => {
|
||||
const { body } = await run(
|
||||
branchGraph('false', [
|
||||
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||||
{ from: 'cond', to: 'no', type: 'ON_FALSE' },
|
||||
]),
|
||||
);
|
||||
expect(body.success).toBe(true);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('no');
|
||||
expect(visited).not.toContain('yes');
|
||||
});
|
||||
|
||||
it('result 是布林但沒有 branch 欄位 → 仍judged得出(相容 {result:true} 形狀)', async () => {
|
||||
const graph = {
|
||||
id: 'g-bool-only',
|
||||
name: '只有 result',
|
||||
nodes: [
|
||||
{ id: 'cond', type: 'Input', data: { result: true } },
|
||||
{ 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 { body } = await run(graph);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('yes');
|
||||
expect(visited).not.toContain('no');
|
||||
});
|
||||
|
||||
it('條件邊的下游拿得到上游 context(propagateCtx 一致)', async () => {
|
||||
const graph = {
|
||||
id: 'g-ctx',
|
||||
name: 'context 傳遞',
|
||||
nodes: [
|
||||
{ id: 'cond', type: 'Input', data: { data: { result: true, branch: 'true' }, carried: 'keep-me' } },
|
||||
{ id: 'yes', type: 'Component', componentId: 'comp_passthrough' },
|
||||
],
|
||||
edges: [{ from: 'cond', to: 'yes', type: 'ON_TRUE' }],
|
||||
};
|
||||
const { body } = await run(graph);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.carried).toBe('keep-me');
|
||||
});
|
||||
|
||||
it('兩條 ON_TRUE 並存 → 都走(同分支多下游是合法 fan-out)', async () => {
|
||||
const graph = {
|
||||
id: 'g-fanout',
|
||||
name: '同分支多下游',
|
||||
nodes: [
|
||||
{ id: 'cond', type: 'Input', data: { data: { result: true, branch: 'true' } } },
|
||||
{ id: 'a', type: 'Component', componentId: 'comp_uppercase', data: { text: 'a' } },
|
||||
{ id: 'b', type: 'Component', componentId: 'comp_uppercase', data: { text: 'b' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'cond', to: 'a', type: 'ON_TRUE' },
|
||||
{ from: 'cond', to: 'b', type: 'ON_TRUE' },
|
||||
],
|
||||
};
|
||||
const { body } = await run(graph);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('a');
|
||||
expect(visited).toContain('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('條件邊:ON_BRANCH(switch 具名分支)', () => {
|
||||
/** switch 零件回 {success, data:{branch:"branch_a"}} */
|
||||
function switchGraph(branch: string) {
|
||||
return {
|
||||
id: 'g-switch',
|
||||
name: 'switch 具名分支',
|
||||
nodes: [
|
||||
{ id: 'sw', type: 'Input', data: { success: true, data: { branch } } },
|
||||
{ id: 'a', type: 'Component', componentId: 'comp_uppercase', data: { text: 'a' } },
|
||||
{ id: 'z', type: 'Component', componentId: 'comp_uppercase', data: { text: 'z' } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'sw', to: 'a', type: 'ON_BRANCH', branch: 'branch_a' },
|
||||
{ from: 'sw', to: 'z', type: 'ON_BRANCH', branch: 'fallback' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('branch=branch_a → 只走標 branch_a 的邊', async () => {
|
||||
const { body } = await run(switchGraph('branch_a'));
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('a');
|
||||
expect(visited).not.toContain('z');
|
||||
});
|
||||
|
||||
it('branch=fallback → 只走標 fallback 的邊', async () => {
|
||||
const { body } = await run(switchGraph('fallback'));
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('z');
|
||||
expect(visited).not.toContain('a');
|
||||
});
|
||||
|
||||
it('沒有任何邊匹配 → 誠實地不走(不亂挑一條,也不報錯)', async () => {
|
||||
const { body } = await run(switchGraph('no_such_branch'));
|
||||
expect(body.success).toBe(true);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).not.toContain('a');
|
||||
expect(visited).not.toContain('z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('通用具名分支涵蓋三型零件(leo 08-01:switch 比 if 更嚴重)', () => {
|
||||
/**
|
||||
* 三顆流程控制零件的 output_schema 都收斂到同一個形狀 `data.branch: string`:
|
||||
* if_control → "true" | "false"(布林兩路)
|
||||
* switch → case 的 branch 名 | default_branch(N 路)
|
||||
* try_catch → "try" | "catch"(成功/失敗兩路)
|
||||
* ⇒ 引擎只需要「依標籤選邊」這一個機制,不是為每顆零件開特例。
|
||||
* ON_TRUE / ON_FALSE 只是 if 布林路的語法糖,底層與 ON_BRANCH 同一條路。
|
||||
*/
|
||||
async function branchTo(branch: string, edges: Array<Record<string, unknown>>) {
|
||||
return run({
|
||||
id: `g-generic-${branch}`,
|
||||
name: '通用具名分支',
|
||||
nodes: [
|
||||
{ id: 'ctrl', type: 'Input', data: { success: true, data: { branch } } },
|
||||
{ id: 'p1', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p1' } },
|
||||
{ id: 'p2', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p2' } },
|
||||
{ id: 'p3', type: 'Component', componentId: 'comp_uppercase', data: { text: 'p3' } },
|
||||
],
|
||||
edges,
|
||||
});
|
||||
}
|
||||
|
||||
const threeWay = [
|
||||
{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'branch_active' },
|
||||
{ from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'branch_inactive' },
|
||||
{ from: 'ctrl', to: 'p3', type: 'ON_BRANCH', branch: 'branch_default' },
|
||||
];
|
||||
|
||||
it('switch 多路:branch_active → 只走第一條,其餘兩條不走', async () => {
|
||||
const { body } = await branchTo('branch_active', threeWay);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('p1');
|
||||
expect(visited).not.toContain('p2');
|
||||
expect(visited).not.toContain('p3');
|
||||
});
|
||||
|
||||
it('switch 多路:branch_inactive → 只走第二條', async () => {
|
||||
const { body } = await branchTo('branch_inactive', threeWay);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('p2');
|
||||
expect(visited).not.toContain('p1');
|
||||
expect(visited).not.toContain('p3');
|
||||
});
|
||||
|
||||
it('switch default:無匹配 case 時零件回 default_branch → 走 default 那條', async () => {
|
||||
// 注意:挑 default 是 switch 零件內部的事(它回 default_branch 名);
|
||||
// 引擎這層看到的一律是「一個標籤」,故 default 不需要引擎特別處理。
|
||||
const { body } = await branchTo('branch_default', threeWay);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('p3');
|
||||
expect(visited).not.toContain('p1');
|
||||
expect(visited).not.toContain('p2');
|
||||
});
|
||||
|
||||
it('try_catch 成功路:branch=try → 走 try 邊,不走 catch 邊', async () => {
|
||||
const { body } = await branchTo('try', [
|
||||
{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'try' },
|
||||
{ from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'catch' },
|
||||
]);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('p1');
|
||||
expect(visited).not.toContain('p2');
|
||||
});
|
||||
|
||||
it('try_catch 失敗路:branch=catch → 走 catch 邊,不走 try 邊', async () => {
|
||||
const { body } = await branchTo('catch', [
|
||||
{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'try' },
|
||||
{ from: 'ctrl', to: 'p2', type: 'ON_BRANCH', branch: 'catch' },
|
||||
]);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).toContain('p2');
|
||||
expect(visited).not.toContain('p1');
|
||||
});
|
||||
|
||||
it('ON_TRUE 與 ON_BRANCH branch="true" 等價(語法糖,底層同一條路)', async () => {
|
||||
const sugar = await branchTo('true', [{ from: 'ctrl', to: 'p1', type: 'ON_TRUE' }]);
|
||||
const raw = await branchTo('true', [{ from: 'ctrl', to: 'p1', type: 'ON_BRANCH', branch: 'true' }]);
|
||||
const v1 = (sugar.body.trace ?? []).map(t => t.nodeId);
|
||||
const v2 = (raw.body.trace ?? []).map(t => t.nodeId);
|
||||
expect(v1).toEqual(v2);
|
||||
expect(v1).toContain('p1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('零變化保證:新邊型不影響既有邊', () => {
|
||||
it('ON_TRUE 邊存在時,同圖的 PIPE 邊照常走', async () => {
|
||||
const graph = {
|
||||
id: 'g-mixed',
|
||||
name: '混合邊',
|
||||
nodes: [
|
||||
{ id: 'cond', type: 'Input', data: { data: { result: false, branch: 'false' }, count: 0 } },
|
||||
{ id: 'yes', type: 'Component', componentId: 'comp_uppercase', data: { text: 'yes' } },
|
||||
{ id: 'always', type: 'Component', componentId: 'comp_counter' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'cond', to: 'yes', type: 'ON_TRUE' },
|
||||
{ from: 'cond', to: 'always', type: 'PIPE' },
|
||||
],
|
||||
};
|
||||
const { body } = await run(graph);
|
||||
const visited = (body.trace ?? []).map(t => t.nodeId);
|
||||
expect(visited).not.toContain('yes'); // 條件邊擋掉
|
||||
expect(visited).toContain('always'); // PIPE 不受影響
|
||||
});
|
||||
|
||||
it('/validate 接受 ON_TRUE / ON_FALSE / ON_BRANCH(schema 已放行)', async () => {
|
||||
const res = await SELF.fetch('http://localhost/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: 'g-validate',
|
||||
name: 'schema 驗證',
|
||||
nodes: [
|
||||
{ id: 'a', type: 'Input' },
|
||||
{ id: 'b', type: 'Output' },
|
||||
{ id: 'c', type: 'Output' },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'a', to: 'b', type: 'ON_TRUE' },
|
||||
{ from: 'a', to: 'c', type: 'ON_FALSE' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as { valid: boolean };
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 意圖語法寫得出條件分支 → 編圖帶對邊型與標籤(SDD workflow-discovery 3.11)
|
||||
*
|
||||
* 為什麼補這一支(08-01 施工中自查發現的斷點,差點漏掉):
|
||||
* 引擎支援了 ON_TRUE/ON_FALSE/ON_BRANCH,skill 文件也教了寫法,
|
||||
* 但 `graph-builder` 原本**只認得 `對每個 X` 的參數化 label**,
|
||||
* `ON_BRANCH(branch_active)` 這種帶括號的 label 會落到 `toEdgeType` 的預設值 **PIPE**
|
||||
* ⇒ 「教了語法但引擎不收,而且是靜默的」——比沒做更糟(AI 以為分支了,實際全走同一條)。
|
||||
*
|
||||
* 本檔守的就是「文件教的寫法,編圖真的收得到」這條線。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildExecutionGraph } from '../src/actions/graph-builder';
|
||||
import { parseTriplets, resolveNodeRole } from '../src/actions/triplet-parser';
|
||||
|
||||
/** 把意圖字串編成圖(走 AI 真正會走的那條路:triplets → graph)。
|
||||
* nodeResults 用「全部 found」的最小替身——本檔只驗**邊**的編法,零件解析另有測試。 */
|
||||
function build(triplets: string[]) {
|
||||
const parsed = parseTriplets(triplets)!;
|
||||
const nodeResults: Record<string, { status: 'found'; componentId: string; type: ReturnType<typeof resolveNodeRole> }> = {};
|
||||
for (const name of parsed.nodeNames) {
|
||||
nodeResults[name] = {
|
||||
status: 'found',
|
||||
componentId: name.toLowerCase().replace(/\s+/g, '_'),
|
||||
type: resolveNodeRole(name, parsed),
|
||||
};
|
||||
}
|
||||
return buildExecutionGraph(parsed, nodeResults as never, 'test-graph', '測試');
|
||||
}
|
||||
|
||||
function edgeBetween(graph: ReturnType<typeof build>, from: string, to: string) {
|
||||
return graph.edges.find(e => e.from === from && e.to === to);
|
||||
}
|
||||
|
||||
describe('意圖語法:if_control 兩路(ON_TRUE/ON_FALSE)', () => {
|
||||
it('ON_TRUE/ON_FALSE 編成對應邊型,不會退化成 PIPE', () => {
|
||||
const g = build([
|
||||
'input >> ON_SUCCESS >> 判斷有沒有新資料',
|
||||
'判斷有沒有新資料 >> ON_TRUE >> 傳到telegram',
|
||||
'判斷有沒有新資料 >> ON_FALSE >> 結束',
|
||||
]);
|
||||
expect(edgeBetween(g, '判斷有沒有新資料', '傳到telegram')?.type).toBe('ON_TRUE');
|
||||
expect(edgeBetween(g, '判斷有沒有新資料', '結束')?.type).toBe('ON_FALSE');
|
||||
});
|
||||
|
||||
it('中文語意詞「成立時」「否則」也編得出來', () => {
|
||||
const g = build([
|
||||
'判斷有沒有新資料 >> 成立時 >> 傳到telegram',
|
||||
'判斷有沒有新資料 >> 否則 >> 結束',
|
||||
]);
|
||||
expect(edgeBetween(g, '判斷有沒有新資料', '傳到telegram')?.type).toBe('ON_TRUE');
|
||||
expect(edgeBetween(g, '判斷有沒有新資料', '結束')?.type).toBe('ON_FALSE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('意圖語法:switch 具名分支(ON_BRANCH(標籤))', () => {
|
||||
it('括號裡的標籤被抽成 edge.branch,型別是 ON_BRANCH', () => {
|
||||
const g = build([
|
||||
'my_switch >> ON_BRANCH(branch_active) >> 處理啟用',
|
||||
'my_switch >> ON_BRANCH(branch_pending) >> 處理待辦',
|
||||
'my_switch >> ON_BRANCH(branch_default) >> 其他',
|
||||
]);
|
||||
const active = edgeBetween(g, 'my_switch', '處理啟用');
|
||||
expect(active?.type).toBe('ON_BRANCH');
|
||||
expect(active?.branch).toBe('branch_active');
|
||||
|
||||
const pending = edgeBetween(g, 'my_switch', '處理待辦');
|
||||
expect(pending?.branch).toBe('branch_pending');
|
||||
|
||||
const dflt = edgeBetween(g, 'my_switch', '其他');
|
||||
expect(dflt?.branch).toBe('branch_default');
|
||||
});
|
||||
|
||||
it('全形括號也收(中文輸入法常打出全形)', () => {
|
||||
const g = build(['my_switch >> ON_BRANCH(branch_active) >> 處理啟用']);
|
||||
const e = edgeBetween(g, 'my_switch', '處理啟用');
|
||||
expect(e?.type).toBe('ON_BRANCH');
|
||||
expect(e?.branch).toBe('branch_active');
|
||||
});
|
||||
|
||||
it('try_catch 的 try/catch 標籤同樣收得到', () => {
|
||||
const g = build([
|
||||
'my_try >> ON_BRANCH(try) >> 正常流程',
|
||||
'my_try >> ON_BRANCH(catch) >> 補救流程',
|
||||
]);
|
||||
expect(edgeBetween(g, 'my_try', '正常流程')?.branch).toBe('try');
|
||||
expect(edgeBetween(g, 'my_try', '補救流程')?.branch).toBe('catch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('零變化:既有語法不受影響', () => {
|
||||
it('ON_SUCCESS 仍是 ON_SUCCESS', () => {
|
||||
const g = build(['input >> ON_SUCCESS >> prep']);
|
||||
expect(edgeBetween(g, 'input', 'prep')?.type).toBe('ON_SUCCESS');
|
||||
});
|
||||
|
||||
it('「對每個 X」仍抽得到 iterator(不被新的 branch 抽取干擾)', () => {
|
||||
const g = build(['parse_card >> 對每個 block >> post_block']);
|
||||
const e = edgeBetween(g, 'parse_card', 'post_block');
|
||||
expect(e?.type).toBe('FOREACH');
|
||||
expect(e?.iterator).toBe('block');
|
||||
expect(e?.branch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* recipe payload 與回應處理層 —— CP `arcrun-usable` 步驟 5 缺口②
|
||||
* SDD: workflow-discovery task 3.12
|
||||
*
|
||||
* 為什麼要有這三層(別刪):
|
||||
* 舊 schema 只有 {canonical_id, endpoint, method, auth_service}(body 有但淺)
|
||||
* ⇒ 帶 body 的 API 只能繞過 recipe 把整包寫進 workflow code;
|
||||
* 回應解析(rag_chat 的 finalize,2786 字元)綁死 Gemini 格式,換源必壞。
|
||||
* leo:三層模型=①零件 ②auth recipe ③payload recipe,第③層過去不存在。
|
||||
*
|
||||
* 本檔測純函式層(body_template 插值 / response_map 正規化),
|
||||
* 不打真外部 API——外部呼叫由 stage 端到端驗(features/09)。
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderBodyTemplate, applyResponseMap } from '../src/lib/recipe-payload';
|
||||
|
||||
describe('body_template:payload 收回 recipe(第③層)', () => {
|
||||
it('巢狀結構的 {{var}} 都會被替換(不只 top-level)', () => {
|
||||
const out = renderBodyTemplate(
|
||||
{ contents: [{ parts: [{ text: '{{prompt}}' }] }] },
|
||||
{ prompt: '你好' },
|
||||
);
|
||||
expect(out).toEqual({ contents: [{ parts: [{ text: '你好' }] }] });
|
||||
});
|
||||
|
||||
it('單一引用保留原型別(陣列/物件不被 stringify)', () => {
|
||||
const out = renderBodyTemplate(
|
||||
{ messages: '{{history}}', n: '{{count}}' },
|
||||
{ history: [{ role: 'user' }], count: 3 },
|
||||
) as Record<string, unknown>;
|
||||
expect(out.messages).toEqual([{ role: 'user' }]);
|
||||
expect(out.n).toBe(3);
|
||||
});
|
||||
|
||||
it('混合文字仍拼成字串', () => {
|
||||
const out = renderBodyTemplate({ q: '請回答:{{prompt}}' }, { prompt: '天氣' }) as Record<string, unknown>;
|
||||
expect(out.q).toBe('請回答:天氣');
|
||||
});
|
||||
|
||||
it('支援 dot path 取值', () => {
|
||||
const out = renderBodyTemplate({ t: '{{assemble.data.prompt}}' }, {
|
||||
assemble: { data: { prompt: '深層值' } },
|
||||
}) as Record<string, unknown>;
|
||||
expect(out.t).toBe('深層值');
|
||||
});
|
||||
|
||||
it('取不到的變數保留原樣(不靜默變 undefined,看得見才好 debug)', () => {
|
||||
const out = renderBodyTemplate({ t: '{{nope}}' }, {}) as Record<string, unknown>;
|
||||
expect(out.t).toBe('{{nope}}');
|
||||
});
|
||||
|
||||
it('沒有 body_template → 回 undefined(呼叫端沿用既有行為)', () => {
|
||||
expect(renderBodyTemplate(undefined, { a: 1 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('response_map:回應正規化(換源不必改 workflow)', () => {
|
||||
const geminiBody = {
|
||||
candidates: [{ content: { parts: [{ text: '【答】台北是首都' }] } }],
|
||||
};
|
||||
|
||||
it('path 取值:Gemini 形狀 → 純文字', () => {
|
||||
const out = applyResponseMap(geminiBody, { text_path: 'candidates.0.content.parts.0.text' });
|
||||
expect(out.text).toBe('【答】台北是首都');
|
||||
});
|
||||
|
||||
it('換源=換 recipe:Claude 形狀用不同 path,同樣取得出文字', () => {
|
||||
const claudeBody = { content: [{ type: 'text', text: 'Claude 的答案' }] };
|
||||
const out = applyResponseMap(claudeBody, { text_path: 'content.0.text' });
|
||||
expect(out.text).toBe('Claude 的答案');
|
||||
});
|
||||
|
||||
it('Workers AI 形狀(binding 回傳)同樣走 path', () => {
|
||||
const waiBody = { response: 'Workers AI 的答案' };
|
||||
const out = applyResponseMap(waiBody, { text_path: 'response' });
|
||||
expect(out.text).toBe('Workers AI 的答案');
|
||||
});
|
||||
|
||||
it('思考型模型:thought=true 的 part 要被剔除,取最後一個非 thought', () => {
|
||||
const gemma = {
|
||||
candidates: [{
|
||||
content: {
|
||||
parts: [
|
||||
{ text: '讓我想想…', thought: true },
|
||||
{ text: '真正的答案' },
|
||||
],
|
||||
},
|
||||
}],
|
||||
};
|
||||
const out = applyResponseMap(gemma, {
|
||||
text_path: 'candidates.0.content.parts',
|
||||
thinking_model: true,
|
||||
});
|
||||
expect(out.text).toBe('真正的答案');
|
||||
});
|
||||
|
||||
it('淨化規則:剝掉【答】前的草稿前綴(實撞三型之一)', () => {
|
||||
const out = applyResponseMap(
|
||||
{ r: 'Draft: 【答】正確內容' },
|
||||
{ text_path: 'r', strip_prefixes: ['Draft:', '*', 'Answer:'], answer_marker: '【答】' },
|
||||
);
|
||||
expect(out.text).toBe('正確內容');
|
||||
});
|
||||
|
||||
it('淨化規則:前綴組合順序不定 → 循環剝殼剝乾淨', () => {
|
||||
const out = applyResponseMap(
|
||||
{ r: 'Answer: * 【答】內容' },
|
||||
{ text_path: 'r', strip_prefixes: ['Draft:', '*', 'Answer:'], answer_marker: '【答】' },
|
||||
);
|
||||
expect(out.text).toBe('內容');
|
||||
});
|
||||
|
||||
it('沒有 response_map → 原樣回傳(既有 recipe 行為完全不變)', () => {
|
||||
const out = applyResponseMap(geminiBody, undefined);
|
||||
expect(out.text).toBeUndefined();
|
||||
expect(out.raw).toEqual(geminiBody);
|
||||
});
|
||||
|
||||
it('path 取不到 → 誠實回 undefined,不編造', () => {
|
||||
const out = applyResponseMap({ a: 1 }, { text_path: 'b.c.d' });
|
||||
expect(out.text).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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 的 assemble=5509 字元、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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user