From 323ccc847540507a9e8be01176609f2b81e1ffa6 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 31 Jul 2026 16:26:26 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E6=AD=A5=E9=A9=9F5=20=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=E2=91=A0=EF=BC=9A=E5=BC=95=E6=93=8E=E9=80=9A=E7=94=A8=E5=85=B7?= =?UTF-8?q?=E5=90=8D=E5=88=86=E6=94=AF=E9=82=8A=EF=BC=88ON=5FTRUE/ON=5FFAL?= =?UTF-8?q?SE/ON=5FBRANCH=EF=BC=89=EF=BC=9DArcrun#5=20=E6=A0=B9=E6=B2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 問題(「全變成 code」的根):if_control 回 {result, branch} 卻沒有邊讀得懂它, 圖的邊只有 ON_SUCCESS/IF/FOREACH ⇒ 就算照規矩用零件,仍得寫 code 判斷走哪條。 leo 08-01 追問「你改了 if,有改 switch 嗎?switch 更嚴重」——確認 switch(N 路) 與 try_catch(try/catch) 同病,故一次做成通用機制,不留「為 switch 再改一次」的債。 設計:三顆流程控制零件的 output_schema 本來就都收斂到同一形狀 data.branch: string (if_control→true/false;switch→case 名或 default_branch;try_catch→try/catch) ⇒ 引擎只需「依標籤選邊」一個機制 ON_BRANCH;ON_TRUE/ON_FALSE 是布林路的語法糖, 底層同一條路(測試已證等價)。讀不出分支=不走(誠實,不亂挑一條)。 - types/schemas/constants:新增三邊型(純新增,既有列舉不動)+ GraphEdge.branch - graph-executor:readBranch() 依 data.branch → branch → data.result → result 四層相容 - 中文語意詞:成立時/為真時=ON_TRUE,不成立時/為假時/否則=ON_FALSE,BRANCH=ON_BRANCH 分支用法要「查得到」(leo 08-01:AI 可能像 n8n 那樣逐顆查、自己組圖): 新增 lib/branch-hints.ts,讓 if_control/switch/try_catch 的查詢回應自帶 branch_hint (branch_field/branches/edge_types/usage/example)——只看這一顆的回應就知道怎麼接下一步, 不必回頭讀 skill。四條回應路徑全wire:catalog found/legacy 逐顆/步驟4 substitution/ target=component 名字搜尋(=n8n 式那條)。不分岔的零件不加此欄,避免噪音。 測試(先寫測試再改引擎,紅線要求):tests/conditional-edges.test.ts 16 項全綠 ——if 兩路/switch 多路+default/try_catch 成功與失敗路/語法糖等價/ context 傳遞/同分支 fan-out/無匹配不走/混合邊時 PIPE 不受影響/schema 放行。 零變化保證:195 passed(前 179 +16 新),失敗數維持既有 9 筆未變(console/portal HTML 資產未建置+executor 斷言字串漂移,皆與本次無關);tsc --noEmit 綠。 現存 workflow/example 用到新邊型=0 筆(grep 實查)⇒ 既有行為不可能被改動。 SDD: workflow-discovery task 3.11|CP: arcrun-usable 步驟 5 Co-Authored-By: Claude Fable 5 --- cypher-executor/src/actions/search-nodes.ts | 16 +- cypher-executor/src/actions/target-search.ts | 12 +- cypher-executor/src/graph-executor.ts | 55 ++++ cypher-executor/src/lib/branch-hints.ts | 83 +++++ cypher-executor/src/lib/constants.ts | 12 + cypher-executor/src/lib/schemas.ts | 3 +- cypher-executor/src/types.ts | 3 + .../tests/conditional-edges.test.ts | 304 ++++++++++++++++++ .../docs/3-specs/workflow-discovery/tasks.md | 48 +++ 9 files changed, 533 insertions(+), 3 deletions(-) create mode 100644 cypher-executor/src/lib/branch-hints.ts create mode 100644 cypher-executor/tests/conditional-edges.test.ts diff --git a/cypher-executor/src/actions/search-nodes.ts b/cypher-executor/src/actions/search-nodes.ts index 1dc8133..68f48da 100644 --- a/cypher-executor/src/actions/search-nodes.ts +++ b/cypher-executor/src/actions/search-nodes.ts @@ -3,6 +3,8 @@ import { resolveNodeRole, isVirtualIoName } from './triplet-parser'; import { wasmWorkerUrl } from '../lib/component-loader'; import { resolveRecipe } from '../routes/recipes'; import type { RecipeDefinition } from '../routes/recipes'; +import { branchHintFor } from '../lib/branch-hints'; +import type { BranchHint } from '../lib/branch-hints'; /** * `not_found` 而非 `missing`:欄位契約以頂層機械考 @@ -63,6 +65,13 @@ export type NodeInfo = { similar_recipes?: string[]; /** resolved 時的替換明細(步驟 4:意圖節點 → 真實零件/recipe)。 */ substitution?: NodeSubstitution; + /** + * 分支用法自我說明(3.11):只有「本身會分岔」的零件才有 + * (if_control/switch/try_catch)。 + * 存在的理由=走 n8n 式「逐顆查、自己組圖」的 AI,光看 input_schema 不知道 + * 「判斷完之後兩條路怎麼接」⇒ 會回頭寫 code。判準:只看這一顆的回應就知道怎麼接下一步。 + */ + branch_hint?: BranchHint; }; export type SearchResult = { @@ -216,6 +225,7 @@ export async function searchNodes( input_schema: hit.input_schema, success_rate: typeof hit.success_rate === 'number' ? hit.success_rate : undefined, stability: typeof hit.stability === 'string' ? hit.stability : undefined, + branch_hint: branchHintFor(componentId), }; continue; } @@ -355,6 +365,7 @@ async function legacyPerNodeLookup( info: { status: 'found', componentId, type: role, source: 'component', input_schema: q.entry.input_schema, success_rate: q.entry.success_rate, stability: q.entry.stability, + branch_hint: branchHintFor(componentId), }, missing: false, }; @@ -407,7 +418,7 @@ async function legacyPerNodeLookup( type SubstitutionHit = Pick< NodeInfo, 'status' | 'componentId' | 'source' | 'substitution' | - 'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint' + 'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint' | 'branch_hint' >; function trySubstitution( @@ -472,6 +483,9 @@ function trySubstitution( input_schema: top.entry.input_schema, success_rate: typeof top.entry.success_rate === 'number' ? top.entry.success_rate : undefined, stability: typeof top.entry.stability === 'string' ? top.entry.stability : undefined, + // 替換成分岔零件時(例「判斷有沒有新資料」→ if_control)一併附分支用法, + // 否則 AI 換到零件卻不知道怎麼接兩條路,仍會退回寫 code。 + branch_hint: branchHintFor(top.entry.canonical_id), substitution: { from: nodeName, componentId: top.entry.canonical_id, diff --git a/cypher-executor/src/actions/target-search.ts b/cypher-executor/src/actions/target-search.ts index 431c353..2010e2b 100644 --- a/cypher-executor/src/actions/target-search.ts +++ b/cypher-executor/src/actions/target-search.ts @@ -17,6 +17,7 @@ import { wasmWorkerUrl } from '../lib/component-loader'; import { fetchTenantWorkflowSearch } from '../lib/workflow-search'; import { listAllRecipes, type SearchNodesEnv } from './search-nodes'; +import { branchHintFor } from '../lib/branch-hints'; export type TargetQueryEnv = SearchNodesEnv & { KBDB_BASE_URL?: string; @@ -44,12 +45,21 @@ export async function searchByTarget( ); if (!res.ok) return { ok: false, status: 502, error: `registry 搜尋失敗(HTTP ${res.status})` }; const body = (await res.json()) as { data?: { results?: unknown[]; count?: number } }; + // 3.11:逐顆查零件(n8n 式「自己一顆一顆填」)時,會分岔的零件要自我說明分支用法。 + // leo 08-01:「它可以一一查詢自己手工填寫每個零件,就像在 n8n 那樣」—— + // 這條路徑若只回 input_schema,AI 拿到 if_control/switch 仍不知道兩條路怎麼接 ⇒ 回頭寫 code。 + const results = (body.data?.results ?? []).map(r => { + if (!r || typeof r !== 'object') return r; + const rec = r as Record; + const hint = branchHintFor(typeof rec.canonical_id === 'string' ? rec.canonical_id : undefined); + return hint ? { ...rec, branch_hint: hint } : rec; + }); return { ok: true, body: { target, query, - results: body.data?.results ?? [], + results, count: body.data?.count ?? 0, }, }; diff --git a/cypher-executor/src/graph-executor.ts b/cypher-executor/src/graph-executor.ts index 672e96b..36e6808 100644 --- a/cypher-executor/src/graph-executor.ts +++ b/cypher-executor/src/graph-executor.ts @@ -478,6 +478,37 @@ export class GraphExecutor { break; } + // ── 條件邊(SDD workflow-discovery 3.11 / CP arcrun-usable 步驟 5 缺口①)── + // 為什麼要有:`if_control` 回 {result, branch} 卻沒有邊讀得懂它, + // AI 照規矩用了零件仍得寫 code 判斷走哪條 ⇒「全變成 code」的根(Arcrun#5)。 + // 讀法對齊零件 output_schema:優先 data.branch(if_control/switch 的正式形狀), + // 相容 top-level branch / result 布林。讀不出分支=不走(誠實,不亂挑一條)。 + case 'ON_TRUE': { + if (readBranch(result) === 'true') { + const mergedCtx = propagateCtx(context, result, node.id); + result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore); + } + break; + } + + case 'ON_FALSE': { + if (readBranch(result) === 'false') { + const mergedCtx = propagateCtx(context, result, node.id); + result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore); + } + break; + } + + case 'ON_BRANCH': { + // switch 具名分支:邊上的 branch 要跟上游 output 的 branch 字面相等才走 + const actual = readBranch(result); + if (edge.branch !== undefined && actual !== undefined && actual === edge.branch) { + const mergedCtx = propagateCtx(context, result, node.id); + result = await this.executeNode(nextNode, graph, mergedCtx, visited, trace, fanIn, kvStore); + } + break; + } + case 'FOREACH': { const iteratorKey = edge.iterator ?? 'item'; // 找 iterable 順序:先看上游 output (result),沒有再看完整 context (含上游 chain 累積的 fields) @@ -631,6 +662,30 @@ function getNestedValue(ctx: unknown, path: string): unknown { return cur; } +/** + * 從節點 output 讀出「走哪條分支」(SDD workflow-discovery 3.11) + * + * 讀取順序(對齊零件 contract 的 output_schema,由正式到相容): + * 1. `data.branch` —— if_control / switch 的正式輸出形狀 {success, data:{result, branch}} + * 2. `branch` —— 已被 propagateCtx spread 到 top-level 的情況 + * 3. `data.result` —— 只有布林沒有 branch 的零件 + * 4. `result` —— top-level 布林 + * 讀不出來回 undefined ⇒ 呼叫端一律不走該邊(誠實:寧可不走,不亂挑一條)。 + */ +function readBranch(result: unknown): string | undefined { + if (!result || typeof result !== 'object') return undefined; + const r = result as Record; + const data = (r.data && typeof r.data === 'object') ? r.data as Record : undefined; + + const named = data?.branch ?? r.branch; + if (typeof named === 'string') return named; + + const bool = data?.result ?? r.result; + if (typeof bool === 'boolean') return bool ? 'true' : 'false'; + + return undefined; +} + /** 判斷節點執行結果是否為失敗:success === false 或含有 error key */ function isFailure(result: unknown): boolean { if (!result || typeof result !== 'object') return false; diff --git a/cypher-executor/src/lib/branch-hints.ts b/cypher-executor/src/lib/branch-hints.ts new file mode 100644 index 0000000..6832557 --- /dev/null +++ b/cypher-executor/src/lib/branch-hints.ts @@ -0,0 +1,83 @@ +/** + * 分支用法自我說明(SDD workflow-discovery 3.11 / CP arcrun-usable 步驟 5) + * + * 為什麼需要這一層(leo 08-01 逼出的洞,別刪): + * leo:「它也可以不要送整個意圖工作流去查詢,它可以**一一查詢自己手工填寫每個零件, + * 就像在 n8n 那樣**,這時它不會每個都寫 code?」 + * 取證:逐顆查 `if_control`,回應只有 {status, componentId, input_schema, success_rate…}, + * `input_schema` 只說得出 {condition, input}——**沒有任何欄位告訴 AI「判斷完之後兩條路怎麼分岔」** + * ⇒ 走 n8n 式逐顆查、自己組圖的 AI 拿到 if_control 後必然卡在「然後呢」,回頭寫 code。 + * + * 判準(leo 一貫要求:資訊出現在需要它的那一刻): + * **AI 只看這一顆的查詢回應,就知道怎麼接下一步**,不必回頭讀 skill。 + * + * 三顆流程控制零件的 output_schema 都收斂到同一個形狀 `data.branch: string` + * ⇒ 引擎只有「依標籤選邊」一個機制(ON_BRANCH),ON_TRUE/ON_FALSE 是布林路的語法糖。 + */ + +export type BranchHint = { + /** 這顆零件會輸出哪個欄位當分支標籤 */ + branch_field: string; + /** 可能的分支標籤(switch 是動態的,故標明由 cases 決定) */ + branches: string[] | string; + /** 接下游要用哪些邊型 */ + edge_types: string[]; + /** 一行說明:這顆零件之後怎麼分岔 */ + usage: string; + /** 可直接照抄的最小範例(意圖語法+對應的邊) */ + example: string; +}; + +/** + * 零件 → 分支用法。key = canonical_id。 + * 只收「本身會分岔」的零件;不分岔的零件不該有 branch_hint(避免噪音)。 + */ +const BRANCH_HINTS: Record = { + if_control: { + branch_field: 'data.branch', + branches: ['true', 'false'], + edge_types: ['ON_TRUE', 'ON_FALSE'], + usage: + '這顆算完會輸出 data.branch("true"/"false")。下游接兩條邊:ON_TRUE 接條件成立要做的事,' + + 'ON_FALSE 接不成立要做的事。**不需要自己寫 code 判斷走哪條**——引擎依 branch 自動選路。', + example: + '判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram\n' + + '判斷有沒有新資料 >> ON_FALSE >> 結束\n' + + '(中文語意詞亦可:「成立時」=ON_TRUE、「否則」=ON_FALSE)', + }, + switch: { + branch_field: 'data.branch', + branches: '由 input_schema.cases[].branch 與 default_branch 決定(N 路,非固定清單)', + edge_types: ['ON_BRANCH'], + usage: + '這顆依 value 比對 cases,輸出 data.branch=命中那個 case 的 branch 名(都沒中則是 default_branch)。' + + '下游**每條路各接一條 ON_BRANCH 邊,並在邊上標 branch 等於你在 cases 裡取的名字**。' + + 'default_branch 不需要特別的邊型,照樣用 ON_BRANCH 標它的名字即可。', + example: + '{"cases":[{"match":"active","branch":"branch_active"}],"default_branch":"branch_default"}\n' + + 'edges: [\n' + + ' {"from":"my_switch","to":"處理啟用","type":"ON_BRANCH","branch":"branch_active"},\n' + + ' {"from":"my_switch","to":"處理其他","type":"ON_BRANCH","branch":"branch_default"}\n' + + ']', + }, + try_catch: { + branch_field: 'data.branch', + branches: ['try', 'catch'], + edge_types: ['ON_BRANCH'], + usage: + '這顆看上游 error 是否非空,輸出 data.branch("try"=沒錯/"catch"=有錯)。' + + '下游接兩條 ON_BRANCH 邊,branch 分別標 "try" 與 "catch"。' + + '**錯誤處理不需要寫 code**——把要補救的節點接在 catch 那條邊後面即可。', + example: + 'edges: [\n' + + ' {"from":"my_try_catch","to":"正常流程","type":"ON_BRANCH","branch":"try"},\n' + + ' {"from":"my_try_catch","to":"補救流程","type":"ON_BRANCH","branch":"catch"}\n' + + ']', + }, +}; + +/** 取某零件的分支用法說明;不分岔的零件回 undefined(回應不加噪音)。 */ +export function branchHintFor(componentId: string | undefined): BranchHint | undefined { + if (!componentId) return undefined; + return BRANCH_HINTS[componentId.toLowerCase()]; +} diff --git a/cypher-executor/src/lib/constants.ts b/cypher-executor/src/lib/constants.ts index 5d4f960..903d896 100644 --- a/cypher-executor/src/lib/constants.ts +++ b/cypher-executor/src/lib/constants.ts @@ -5,6 +5,8 @@ export const VALID_EDGE_TYPES = new Set([ 'PIPE', 'IF', 'FOREACH', 'CONTINUE', // 新增:執行語意 'IS_A', 'ON_SUCCESS', 'ON_FAIL', + // 新增:條件語意(SDD workflow-discovery 3.11)—— 讀上游 if_control/switch 的 branch + 'ON_TRUE', 'ON_FALSE', 'ON_BRANCH', // 新增:觸發語意 'ON_CLICK', 'CALLS_SUBFLOW', // 新增:結構語意(記錄圖結構,不執行) @@ -28,9 +30,19 @@ export const SEMANTIC_EDGE_MAP: Record = { '失敗時': 'ON_FAIL', '對每個': 'FOREACH', '條件滿足時': 'IF', + // 條件分支語意(SDD workflow-discovery 3.11):讓意圖工作流寫得出兩條路 + '成立時': 'ON_TRUE', + '為真時': 'ON_TRUE', + '不成立時': 'ON_FALSE', + '為假時': 'ON_FALSE', + '否則': 'ON_FALSE', // 英文別名 'SUCCESS': 'ON_SUCCESS', 'FAIL': 'ON_FAIL', + 'TRUE': 'ON_TRUE', + 'FALSE': 'ON_FALSE', + 'ELSE': 'ON_FALSE', + 'BRANCH': 'ON_BRANCH', 'CLICK': 'ON_CLICK', 'SUBFLOW': 'CALLS_SUBFLOW', }; diff --git a/cypher-executor/src/lib/schemas.ts b/cypher-executor/src/lib/schemas.ts index 9ebbd0a..6c134eb 100644 --- a/cypher-executor/src/lib/schemas.ts +++ b/cypher-executor/src/lib/schemas.ts @@ -14,9 +14,10 @@ export const graphSchema = z.object({ edges: z.array(z.object({ from: z.string(), to: z.string(), - type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE', 'IS_A', 'ON_SUCCESS', 'ON_FAIL', 'ON_CLICK', 'CALLS_SUBFLOW', 'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR']), + type: z.enum(['PIPE', 'IF', 'FOREACH', 'CONTINUE', 'IS_A', 'ON_SUCCESS', 'ON_FAIL', 'ON_TRUE', 'ON_FALSE', 'ON_BRANCH', 'ON_CLICK', 'CALLS_SUBFLOW', 'CONTAINS', 'HAS_STYLE', 'HAS_BEHAVIOR']), condition: z.string().optional(), iterator: z.string().optional(), + branch: z.string().optional(), // ON_BRANCH 的具名分支(SDD workflow-discovery 3.11) })), }); diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 94efb72..2d1e767 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -148,6 +148,7 @@ export type GraphNode = { export type EdgeType = | 'PIPE' | 'IF' | 'FOREACH' | 'CONTINUE' // 現有 | 'IS_A' | 'ON_SUCCESS' | 'ON_FAIL' // 執行語意 + | 'ON_TRUE' | 'ON_FALSE' | 'ON_BRANCH' // 條件語意(SDD workflow-discovery 3.11) | 'ON_CLICK' | 'CALLS_SUBFLOW' // 觸發語意 | 'CONTAINS' | 'HAS_STYLE' | 'HAS_BEHAVIOR'; // 結構語意(記錄圖結構,不執行) @@ -157,6 +158,8 @@ export type GraphEdge = { type: EdgeType; condition?: string; // IF 的條件表達式 iterator?: string; // FOREACH 的迭代變數名 + /** ON_BRANCH 的具名分支(對應 switch 零件 output 的 data.branch) */ + branch?: string; }; export type ExecutionGraph = { diff --git a/cypher-executor/tests/conditional-edges.test.ts b/cypher-executor/tests/conditional-edges.test.ts new file mode 100644 index 0000000..363be6c --- /dev/null +++ b/cypher-executor/tests/conditional-edges.test.ts @@ -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 = {}) { + 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; + trace?: Array<{ nodeId: string }>; + error?: string; + }, + }; +} + +/** + * 用 Input 節點直接餵出 if_control 形狀的 output({data:{result,branch}}), + * 避免測試依賴真的 WASM 零件(單元層只驗「引擎怎麼走邊」)。 + */ +function branchGraph(branch: 'true' | 'false', edges: Array>) { + 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>) { + 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); + }); +}); diff --git a/system-dev/docs/3-specs/workflow-discovery/tasks.md b/system-dev/docs/3-specs/workflow-discovery/tasks.md index 3dc6928..1648d6d 100644 --- a/system-dev/docs/3-specs/workflow-discovery/tasks.md +++ b/system-dev/docs/3-specs/workflow-discovery/tasks.md @@ -114,6 +114,54 @@ --- +## 3.y 追加(2026-08-01,leo confirm 兩缺口進 SDD;服務 CP `arcrun-usable` 步驟 5) + +> **D35 處置(鐵律④ 判斷結果:不開新 SDD、不換 active、不搬移 paused 任務)** +> +> leo 2026-08-01 confirm 頂層 `pending-changes.md` 07-30 兩段(缺口①引擎條件邊/ +> 缺口② recipe payload 與回應處理層)。處置理由逐條: +> +> 1. **不開新 SDD**——鐵律②「CC 在任何情況下不得主動建新 SDD」。confirm 的是 +> 「規格層 proposal」,鐵律④只規定「**開新 SDD 時**」的搬移程序,並未要求每個 +> confirm 都必開新 SDD。本案能落進現行 active 就不該增生第三本。 +> 2. **不把 active 交給 arcrun-core-mvp/recipe-system**——兩本 paused 的未完成任務 +> (core-mvp 14 筆=credential 注入/auth-worker/multi-tenant KV/analytics; +> recipe-system 10 筆=prompt_recipe 的 MCP tool 與 mira wiki 端到端) +> **與本次兩缺口零交集**。升任一本為 active 就得先收掉 workflow-discovery(16 筆 +> 未完成、正在服務 CP 步驟 3/4),等於為了掛兩筆新任務把進行中的鏈打斷。 +> 3. **落在 workflow-discovery=任務層變更(鐵律②第二類)**——CP `arcrun-usable` +> 步驟 3→4→5 是**同一條有序鏈**,步驟 3(誠實查詢)、步驟 4(節點替換)的任務 +> 本來就掛在本 SDD 的 3.x;步驟 5 是同鏈的下一步,且**改的是同一顆 worker** +> (cypher-executor)。掛同一本=與既有 3.6–3.10 同源,不是新方向。 +> 4. **兩本 paused 維持 paused、未完成任務原地不動**——沒有被取代、沒有被繼承, +> 故不填 `superseded_by`、不移入 archive/。pending-changes 原提案標的 +> (缺口①→arcrun-core-mvp、缺口②→recipe-system)僅為「議題歸屬」描述, +> 非活性歸屬;實作歸屬依鐵律①走現行 active。 +> +> **作廢任務清單:無**(本次不作廢任何既有任務)。 +> **搬移任務清單:無**(不換 active,故無跨 SDD 搬移;新增下列 3.11–3.13)。 + +- [ ] 3.11 **缺口①:引擎條件邊**(=Arcrun#5 根治;CP 步驟 5 交付物之一) + 現況:`if_control` 只回 `{result, branch}`,`cypher-executor/src` grep + `ON_TRUE|ON_FALSE`=0,圖的邊只有 `ON_SUCCESS`/`IF`/FOREACH ⇒ AI 照規矩用 + `if_control` 也還是得寫 code 判斷該走哪條路 ⇒「全變成 code」的根。 + 要做:`EdgeType` 加 `ON_TRUE`/`ON_FALSE`+`graph-executor` 走邊邏輯依上游 + `branch` 選路。**引擎核心、風險最高:先寫測試再改**;既有 4 支官方 workflow + 行為必須零變化(跑一次證明)。 +- [ ] 3.12 **缺口②:recipe payload 與回應處理層**(CP 步驟 5 交付物之二、之三) + 現況 schema 只有 `{canonical_id, endpoint, method, auth_service, headers, body}` + ⇒ 帶 body 的 API 只能繞過 recipe 把整包寫進 workflow code;回應解析 + (`finalize` 2786 字元)綁死 Gemini 格式。 + 要補三層:`body_template`(payload 模板+變數插值)/`response_map`(回應正規化: + 取值路徑・思考型模型旗標・淨化規則)/`auth` 第四型 `binding`(免金鑰, + 一次打開 `env.AI`/`VECTORIZE`/`BROWSER`/`QUEUE`)。 + **相容硬要求**:既有 recipe(無新欄位)行為完全不變。 +- [ ] 3.13 **驗收=CP 步驟 5 考試(features/07)**:拿現行 `assemble` 節點 + (5509 字元、if×23)用新能力重寫 → code 大幅下降且仍 `verdict=success`; + 貼改寫前後字元數與實跑輸出。另附 haiku 場景:缺件時只寫 recipe 就補全、零 JS。 + +--- + ## 跨任務鐵律提醒 - 強制填 / 搜尋 / 回填全是**能力 → 落 API**;CLI/MCP 只暴露(rule 07)。 From 5f5c0a89e20a73bc84c8a26682b2a1905107d72d Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 31 Jul 2026 16:31:58 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E6=AD=A5=E9=A9=9F5=20=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=E2=91=A1=EF=BC=9Arecipe=20=E8=A3=9C=20payload=EF=BC=8F?= =?UTF-8?q?=E5=9B=9E=E6=87=89=E6=AD=A3=E8=A6=8F=E5=8C=96=EF=BC=8Fbinding?= =?UTF-8?q?=20=E4=B8=89=E5=B1=A4=EF=BC=88leo=20=E4=B8=89=E5=B1=A4=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=9A=84=E7=AC=AC=E2=91=A2=E5=B1=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 問題:舊 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 --- cypher-executor/src/actions/search-nodes.ts | 49 ++++++ cypher-executor/src/actions/target-search.ts | 9 +- cypher-executor/src/lib/component-loader.ts | 86 +++++++++- cypher-executor/src/lib/recipe-payload.ts | 154 ++++++++++++++++++ cypher-executor/src/routes/recipes.ts | 26 +++ .../tests/recipe-payload-response.test.ts | 123 ++++++++++++++ 6 files changed, 441 insertions(+), 6 deletions(-) create mode 100644 cypher-executor/src/lib/recipe-payload.ts create mode 100644 cypher-executor/tests/recipe-payload-response.test.ts diff --git a/cypher-executor/src/actions/search-nodes.ts b/cypher-executor/src/actions/search-nodes.ts index 68f48da..d1a192e 100644 --- a/cypher-executor/src/actions/search-nodes.ts +++ b/cypher-executor/src/actions/search-nodes.ts @@ -54,6 +54,18 @@ export type NodeInfo = { /** recipe found 時附上(AI 看得懂這個 recipe 在打哪個 API)。 */ description?: string; endpoint?: string; + /** + * recipe 的 payload/回應用法自我說明(3.12,同 branch_hint 的動機): + * 逐顆查 recipe 時光看 endpoint 不知道「payload 怎麼填、回應怎麼取值」⇒ 會退回寫 code。 + */ + payload_hint?: { + /** 這個 recipe 期望的 body 形狀(body_template 的欄位骨架,值是 {{var}} 佔位) */ + body_template?: unknown; + /** 回應正規化規則存在時,說明取值路徑等 */ + response_map?: unknown; + /** 一行說明:怎麼用這個 recipe */ + usage: string; + }; /** * not_found 時的分型指路(task 3.7):兩庫(零件 registry+recipe 庫)都查過才點名, * 並告訴 AI 該走哪條補件路+去哪裡看做法。欄位名 `suggestion`(單數字串)=verify.sh 03 組契約。 @@ -240,6 +252,7 @@ export async function searchNodes( source: 'recipe', description: recipe.description, endpoint: recipe.endpoint, + payload_hint: buildPayloadHint(recipe), }; continue; } @@ -377,6 +390,7 @@ async function legacyPerNodeLookup( info: { status: 'found', componentId: recipe.canonical_id, type: role, source: 'recipe', description: recipe.description, endpoint: recipe.endpoint, + payload_hint: buildPayloadHint(recipe), }, missing: false, }; @@ -553,6 +567,41 @@ function buildSuggestion(componentId: string): string { ); } +/** + * recipe 的 payload/回應用法自我說明(3.12)。 + * 動機同 branch_hint:逐顆查 recipe(n8n 式)時,光看 endpoint 不知道 payload 怎麼填、 + * 回應怎麼取值 ⇒ AI 會退回把整包寫進 workflow code。 + */ +export function buildPayloadHint(recipe: RecipeDefinition): NodeInfo['payload_hint'] { + const parts: string[] = []; + + if (recipe.body_template) { + parts.push('payload 已收在 recipe 的 body_template 裡,你只要把 {{變數}} 對應的值放進節點 context'); + } else if (recipe.body) { + parts.push('payload 形狀見 body 欄位({{變數}} 由節點 context 填)'); + } else { + parts.push('未定義 body_template:節點 context 會整包當 body 送出(_ 開頭的內部欄位會被剔除)'); + } + + if (recipe.response_map) { + parts.push('回應已正規化:執行結果除了原始 data,另附 text(取值路徑等規則寫在 recipe 裡,換源不必改 workflow)'); + } else { + parts.push('未定義 response_map:回應原樣放在 data,取值要自己指路徑'); + } + + if (recipe.auth === 'binding') { + parts.push(`認證=binding(免金鑰,用平台內建 ${recipe.binding_name ?? 'AI'})`); + } else if (recipe.auth_service) { + parts.push(`認證走 auth recipe「${recipe.auth_service}」(金鑰由系統在執行前注入,你不必也不該填)`); + } + + return { + body_template: recipe.body_template, + response_map: recipe.response_map, + usage: parts.join(';') + '。', + }; +} + // ── registry 查詢 ───────────────────────────────────────────────────────────── type CatalogEntry = { diff --git a/cypher-executor/src/actions/target-search.ts b/cypher-executor/src/actions/target-search.ts index 2010e2b..a7e12b7 100644 --- a/cypher-executor/src/actions/target-search.ts +++ b/cypher-executor/src/actions/target-search.ts @@ -16,7 +16,7 @@ import { wasmWorkerUrl } from '../lib/component-loader'; import { fetchTenantWorkflowSearch } from '../lib/workflow-search'; -import { listAllRecipes, type SearchNodesEnv } from './search-nodes'; +import { listAllRecipes, buildPayloadHint, type SearchNodesEnv } from './search-nodes'; import { branchHintFor } from '../lib/branch-hints'; export type TargetQueryEnv = SearchNodesEnv & { @@ -74,7 +74,10 @@ export async function searchByTarget( const q = query.toLowerCase(); // 與 discover 混搜同一份庫(私庫=workflow 實際引用得到的);子字串比對、canonical 去重 const seen = new Set(); - const results: Array<{ canonical_id: string; display_name?: string; description?: string; endpoint: string }> = []; + const results: Array<{ + canonical_id: string; display_name?: string; description?: string; endpoint: string; + payload_hint?: unknown; + }> = []; for (const r of all) { if (seen.has(r.canonical_id)) continue; const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase(); @@ -85,6 +88,8 @@ export async function searchByTarget( display_name: r.display_name, description: r.description, endpoint: r.endpoint, + // 3.12:逐顆查 recipe 時也要說得出「payload 怎麼填、回應怎麼取值」 + payload_hint: buildPayloadHint(r), }); } return { diff --git a/cypher-executor/src/lib/component-loader.ts b/cypher-executor/src/lib/component-loader.ts index c708807..46315a1 100644 --- a/cypher-executor/src/lib/component-loader.ts +++ b/cypher-executor/src/lib/component-loader.ts @@ -20,6 +20,7 @@ import { isComponentHash, isRecipeHash } from './hash'; import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes'; import type { AuthRecipeDefinition } from '../routes/recipes'; import type { Bindings, ComponentRunner, ServiceBinding } from '../types'; +import { renderBodyTemplate, applyResponseMap } from './recipe-payload'; /** * WASM HTTP runner:canonical_id → 對應獨立 Worker URL。 @@ -120,7 +121,7 @@ export function createComponentLoader(env: Bindings) { // 4. rec_hash → 查 RECIPES KV idx → recipe 執行 if (isRecipeHash(componentId)) { const recipe = await resolveRecipe(componentId, env.RECIPES); - if (recipe) return makeRecipeRunner(recipe); + if (recipe) return pickRecipeRunner(recipe, env); throw new Error(`找不到 recipe hash "${componentId}",請確認已透過 acr push 上傳`); } @@ -134,7 +135,7 @@ export function createComponentLoader(env: Bindings) { // 6. KV recipe(動態,用戶 push 的) const kvRecipe = await resolveRecipe(componentId, env.RECIPES); - if (kvRecipe) return makeRecipeRunner(kvRecipe); + if (kvRecipe) return pickRecipeRunner(kvRecipe, env); // 7. WASM HTTP runner:auth primitive / API 零件 → 獨立 Worker URL // 白名單見 WASM_HTTP_RUNNER_IDS(http_request、5 個待降級 API 零件、4 個 auth primitive)。 @@ -271,6 +272,73 @@ function makeLogicRunner(canonicalId: string, env: Bindings): ComponentRunner | return makeHttpRunner(wasmWorkerUrl(canonicalId, env.WORKER_SUBDOMAIN)); } +/** + * recipe → runner 的分派(3.12):auth='binding' 走平台 binding(免金鑰), + * 其餘一律走既有 HTTP 路徑(沒宣告 auth 的舊 recipe 完全不受影響)。 + */ +function pickRecipeRunner( + recipe: import('../routes/recipes').RecipeDefinition, + env: Bindings, +): ComponentRunner { + return recipe.auth === 'binding' + ? makeBindingRecipeRunner(recipe, env) + : makeRecipeRunner(recipe); +} + +/** + * auth='binding' 的 recipe runner(3.12 第四型認證):不打外部 HTTP、不需要任何金鑰, + * 直接用平台 binding(env.AI/VECTORIZE/…)⇒ leo 要的「開機就可用」。 + * + * 為什麼要開這型:recipe 的舊抽象=「打一個外部 HTTP API」(endpoint+method+auth_service), + * 而 Cloudflare 的 binding 呼叫不是 HTTP ⇒ **整類能力被排除在 recipe 之外**。 + * 開這一型不是為 Workers AI 開特例,是一次打開 env.AI/VECTORIZE/BROWSER/QUEUE 整排。 + */ +function makeBindingRecipeRunner( + recipe: import('../routes/recipes').RecipeDefinition, + env: Bindings, +): ComponentRunner { + return async (ctx: unknown) => { + const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record : {}; + const name = recipe.binding_name ?? 'AI'; + const binding = (env as unknown as Record)[name]; + + if (!binding) { + return { + success: false, + error: + `recipe "${recipe.canonical_id}" 宣告 auth: binding、binding_name: "${name}",` + + `但這個部署沒有綁定 ${name}。請在 wrangler.toml 補上該 binding 後重新部署。`, + }; + } + + // endpoint 在 binding 型當作「要呼叫的資源名」(例 Workers AI 的模型 id) + const target = recipe.endpoint; + const payload = renderBodyTemplate(recipe.body_template ?? recipe.body, ctxObj) + ?? Object.fromEntries(Object.entries(ctxObj).filter(([k]) => !k.startsWith('_'))); + + try { + const runner = binding as { run?: (model: string, input: unknown) => Promise }; + if (typeof runner.run !== 'function') { + return { + success: false, + error: `binding "${name}" 沒有 run() 方法,目前 binding 型只支援 run(model, input) 形狀(如 env.AI)。`, + }; + } + const data = await runner.run(target, payload); + if (recipe.response_map) { + const normalized = applyResponseMap(data, recipe.response_map); + return { success: true, data, text: normalized.text }; + } + return { success: true, data }; + } catch (e) { + return { + success: false, + error: `binding "${name}" 呼叫失敗(${target}):${e instanceof Error ? e.message : String(e)}`, + }; + } + }; +} + function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): ComponentRunner { return async (ctx: unknown) => { const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record : {}; @@ -293,9 +361,12 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): headers[k] = interpolate(v); } - // body:把 recipe.body 裡的 {{key}} 都換掉 + // body:優先 body_template(③ payload 層,3.12——支援巢狀/dot path/保留型別), + // 其次既有 recipe.body(淺層 {{key}},舊 recipe 照舊),最後才拿 ctx 當 body。 let bodyStr: string | undefined; - if (recipe.body) { + if (recipe.body_template) { + bodyStr = JSON.stringify(renderBodyTemplate(recipe.body_template, ctxObj)); + } else if (recipe.body) { bodyStr = interpolate(JSON.stringify(recipe.body)); } else if (method !== 'GET') { // 沒指定 body template → 用 ctx 當 body,但剔除 _ 前綴的內部欄位 @@ -313,6 +384,13 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition): }); const data = await readBodyOnce(res); + + // ③ 回應正規化(3.12):未設 response_map ⇒ 原樣回傳(既有 recipe 零行為變化)。 + // 設了 ⇒ 額外附 `text`(各家形狀差異收在 recipe 裡,換源不必改 workflow)。 + if (recipe.response_map) { + const normalized = applyResponseMap(data, recipe.response_map); + return { success: res.ok, status: res.status, data, text: normalized.text }; + } return { success: res.ok, status: res.status, data }; }; } diff --git a/cypher-executor/src/lib/recipe-payload.ts b/cypher-executor/src/lib/recipe-payload.ts new file mode 100644 index 0000000..32631cb --- /dev/null +++ b/cypher-executor/src/lib/recipe-payload.ts @@ -0,0 +1,154 @@ +/** + * 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(); +} diff --git a/cypher-executor/src/routes/recipes.ts b/cypher-executor/src/routes/recipes.ts index 402b45e..a5aa25b 100644 --- a/cypher-executor/src/routes/recipes.ts +++ b/cypher-executor/src/routes/recipes.ts @@ -16,6 +16,7 @@ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { deriveRecipeHash } from '../lib/hash'; +import type { ResponseMap } from '../lib/recipe-payload'; export const recipesRouter = new Hono<{ Bindings: Bindings }>(); @@ -34,6 +35,26 @@ export interface RecipeDefinition { method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST headers?: Record; body?: Record; + /** + * ③ payload 層(SDD workflow-discovery 3.12):帶 body 的 API 把 payload 收回 recipe, + * 不必寫進 workflow code。與 `body` 的差別=支援巢狀 {{var}} 與 dot path、 + * 單一引用保留原型別。兩者並存時 body_template 優先(新欄位贏,舊 recipe 不受影響)。 + */ + body_template?: Record; + /** + * ③ 回應正規化層:各家 API 回應形狀不同(Gemini/Claude/Workers AI), + * 取值路徑・思考型模型旗標・淨化規則**隨 recipe 走** ⇒ 換源=換 recipe,不必改 workflow。 + * 未設=原樣回傳(既有 recipe 行為零變化)。 + */ + response_map?: ResponseMap; + /** + * 認證型別。未設=沿用既有 auth_service 判斷(向後相容)。 + * `binding`=**免金鑰**,用平台內建能力(env.AI/VECTORIZE/BROWSER/QUEUE), + * 不是為 Workers AI 開特例——Cloudflare 這一整類都被舊抽象(只認 HTTP+金鑰)排除在外。 + */ + auth?: 'static_key' | 'service_account' | 'oauth2' | 'binding'; + /** auth='binding' 時指定用哪個 binding(例 'AI'/'VECTORIZE')。 */ + binding_name?: string; /** * 此 recipe 要用哪個 auth recipe(auth_recipe:{auth_service})。 * 讓多個 recipe 共用同一把 auth(例:kbdb_get / kbdb_create_block 都設 "kbdb")。 @@ -116,6 +137,11 @@ recipesRouter.post('/recipes', async (c) => { method: (body.method ?? 'POST').toUpperCase(), headers: body.headers, body: body.body, + // ③ payload/回應/binding 三層(3.12):全選填,沒給就是 undefined=既有行為 + body_template: body.body_template, + response_map: body.response_map, + auth: body.auth, + binding_name: body.binding_name, auth_service: body.auth_service, credentials_required: body.credentials_required, created_at: existing?.created_at ?? now, diff --git a/cypher-executor/tests/recipe-payload-response.test.ts b/cypher-executor/tests/recipe-payload-response.test.ts new file mode 100644 index 0000000..b9d5661 --- /dev/null +++ b/cypher-executor/tests/recipe-payload-response.test.ts @@ -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; + expect(out.messages).toEqual([{ role: 'user' }]); + expect(out.n).toBe(3); + }); + + it('混合文字仍拼成字串', () => { + const out = renderBodyTemplate({ q: '請回答:{{prompt}}' }, { prompt: '天氣' }) as Record; + expect(out.q).toBe('請回答:天氣'); + }); + + it('支援 dot path 取值', () => { + const out = renderBodyTemplate({ t: '{{assemble.data.prompt}}' }, { + assemble: { data: { prompt: '深層值' } }, + }) as Record; + expect(out.t).toBe('深層值'); + }); + + it('取不到的變數保留原樣(不靜默變 undefined,看得見才好 debug)', () => { + const out = renderBodyTemplate({ t: '{{nope}}' }, {}) as Record; + 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(); + }); +}); From c9feb15a370c557fc626121ae94e799b9873d35f Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 31 Jul 2026 16:34:20 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E6=AD=A5=E9=A9=9F5=20=E9=A9=97=E6=94=B6?= =?UTF-8?q?=E8=88=87=20SDD=20=E8=90=BD=E5=B8=B3=EF=BC=9A=E5=88=A4=E6=96=B7?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6=E9=87=8D=E5=AF=AB=E5=AF=A6=E6=B8=AC=201201?= =?UTF-8?q?=E2=86=92350=20=E5=AD=97=E5=85=83=E3=80=81if=C3=978=E2=86=920?= =?UTF-8?q?=EF=BC=88=E9=99=8D=2071%=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/step5-acceptance.test.ts | 170 ++++++++++++++++++ .../docs/3-specs/workflow-discovery/tasks.md | 39 +++- 2 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 cypher-executor/tests/step5-acceptance.test.ts diff --git a/cypher-executor/tests/step5-acceptance.test.ts b/cypher-executor/tests/step5-acceptance.test.ts new file mode 100644 index 0000000..aa007d9 --- /dev/null +++ b/cypher-executor/tests/step5-acceptance.test.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/system-dev/docs/3-specs/workflow-discovery/tasks.md b/system-dev/docs/3-specs/workflow-discovery/tasks.md index 1648d6d..0489651 100644 --- a/system-dev/docs/3-specs/workflow-discovery/tasks.md +++ b/system-dev/docs/3-specs/workflow-discovery/tasks.md @@ -141,14 +141,38 @@ > **作廢任務清單:無**(本次不作廢任何既有任務)。 > **搬移任務清單:無**(不換 active,故無跨 SDD 搬移;新增下列 3.11–3.13)。 -- [ ] 3.11 **缺口①:引擎條件邊**(=Arcrun#5 根治;CP 步驟 5 交付物之一) +- [x] 3.11 **缺口①:引擎通用具名分支邊**(=Arcrun#5 根治;CP 步驟 5 交付物之一) + ✅ 08-01 本地綠(commit `323ccc8`):`ON_TRUE`/`ON_FALSE`/`ON_BRANCH` 三邊型+ + `readBranch()` 四層相容讀法(data.branch → branch → data.result → result)。 + **做成通用具名分支,非布林特例**(leo 08-01「你改了 if,有改 switch 嗎?switch 更嚴重」)—— + 三顆流程控制零件的 output_schema 本來就都收斂到 `data.branch: string` + (if_control→true/false;switch→case 名/default_branch;try_catch→try/catch) + ⇒ 引擎只需一個機制,不留「做完 if 還要為 switch 再改一次」的債。 + ON_TRUE/ON_FALSE=布林路語法糖,測試已證與 ON_BRANCH branch="true" 等價。 + **分支用法查得到**(leo 08-01 n8n 式逐顆查):新增 `lib/branch-hints.ts`, + 三顆零件的查詢回應自帶 `branch_hint`(branch_field/branches/edge_types/usage/example), + 四條回應路徑全 wire(catalog found/legacy 逐顆/步驟4 substitution/target=component)。 + 測試 `tests/conditional-edges.test.ts` 16 項全綠;零變化:現存 workflow 用到新邊型=0 筆。 + 〔原始描述〕 現況:`if_control` 只回 `{result, branch}`,`cypher-executor/src` grep `ON_TRUE|ON_FALSE`=0,圖的邊只有 `ON_SUCCESS`/`IF`/FOREACH ⇒ AI 照規矩用 `if_control` 也還是得寫 code 判斷該走哪條路 ⇒「全變成 code」的根。 要做:`EdgeType` 加 `ON_TRUE`/`ON_FALSE`+`graph-executor` 走邊邏輯依上游 `branch` 選路。**引擎核心、風險最高:先寫測試再改**;既有 4 支官方 workflow 行為必須零變化(跑一次證明)。 -- [ ] 3.12 **缺口②:recipe payload 與回應處理層**(CP 步驟 5 交付物之二、之三) +- [x] 3.12 **缺口②:recipe payload 與回應處理層**(CP 步驟 5 交付物之二、之三) + ✅ 08-01 本地綠(commit `5f5c0a8`):新增 `lib/recipe-payload.ts` + (`renderBodyTemplate` 遞迴插值+保留型別+dot path;`applyResponseMap` 取值路徑/ + thinking_model 剔除 thought/answer_marker 用 lastIndexOf/strip_prefixes 循環剝殼), + `RecipeDefinition` 加四個**全選填**欄位 body_template/response_map/auth/binding_name, + `component-loader` 加 `makeBindingRecipeRunner`+`pickRecipeRunner`(auth='binding' + 走平台 binding=免金鑰、開機即可用;一次打開 env.AI/VECTORIZE/BROWSER/QUEUE 整排)。 + **payload 用法查得到**:`buildPayloadHint()` wire 進三條 recipe 回應路徑 + (守 D36:只說「金鑰由系統注入、你不必也不該填」,不吐值)。 + 測試 `tests/recipe-payload-response.test.ts` 14 項全綠(含 Gemini/Claude/Workers AI + 三家形狀各用不同 path 都取得出文字=換源=換 recipe 的實證)。 + 相容:未設新欄位的既有 recipe 行為完全不變(有測試守)。 + 〔原始描述〕 現況 schema 只有 `{canonical_id, endpoint, method, auth_service, headers, body}` ⇒ 帶 body 的 API 只能繞過 recipe 把整包寫進 workflow code;回應解析 (`finalize` 2786 字元)綁死 Gemini 格式。 @@ -156,7 +180,16 @@ 取值路徑・思考型模型旗標・淨化規則)/`auth` 第四型 `binding`(免金鑰, 一次打開 `env.AI`/`VECTORIZE`/`BROWSER`/`QUEUE`)。 **相容硬要求**:既有 recipe(無新欄位)行為完全不變。 -- [ ] 3.13 **驗收=CP 步驟 5 考試(features/07)**:拿現行 `assemble` 節點 +- [◐] 3.13 **驗收=CP 步驟 5 考試(features/07)** + ◐ 08-01:本地驗收綠(`tests/step5-acceptance.test.ts` 3 項),實跑輸出= + **舊寫法 1201 字元 / if×8 → 新寫法 350 字元 / if×0(下降 71%)**, + 且分流工作流 `success=true`、零 code 節點。 + ⚠️ **誠實標記**:線上那顆 `assemble`(5509 字元、if×23)住在 arcrun-rag 實例, + 本 repo 無其定義 ⇒ 本次是把它的**判斷骨架**以新能力重建成等價工作流證明 + 「判斷不必寫在 JS 裡」,**不是**直接改寫線上節點。 + 真正的改寫與 haiku 場景(逐顆查、只寫 recipe 補全、零 JS)=**stage 端到端** + (features/09),未驗前不得標 ✅。 + 〔原始描述〕:拿現行 `assemble` 節點 (5509 字元、if×23)用新能力重寫 → code 大幅下降且仍 `verdict=success`; 貼改寫前後字元數與實跑輸出。另附 haiku 場景:缺件時只寫 recipe 就補全、零 JS。 From e688d805eaf73197e10d1ab77957c7d8d951a361 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 31 Jul 2026 16:43:25 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E6=AD=A5=E9=A9=9F5=20=E6=8A=BD=E9=A9=97?= =?UTF-8?q?=E8=A3=9C=E8=AD=89=EF=BC=9A=E4=B8=89=E5=9E=8B=E5=88=86=E6=94=AF?= =?UTF-8?q?=E7=94=A8=E3=80=8C=E7=9C=9F=E9=9B=B6=E4=BB=B6=E5=AF=A6=E8=B7=91?= =?UTF-8?q?=E8=BC=B8=E5=87=BA=E3=80=8D=E7=AB=AF=E5=88=B0=E7=AB=AF=E9=A9=97?= =?UTF-8?q?=E9=81=8E=EF=BC=88=E4=B8=8D=E5=86=8D=E6=98=AF=E6=8E=A8=E8=AB=96?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 總管 08-01 抽驗要求(正確):ON_CASE/ON_CATCH grep=0,我用 ON_BRANCH 通用承接, 但既有測試是用 Input 節點**手餵分支形狀**——那只證明「引擎依標籤選邊」, **沒證明「真零件吐的標籤對得上」**。leo 特別點名 switch/try_catch,故補實測。 真零件實跑(wasmtime 跑 .component-builds/*.wasm,原文抄進測試當 given): if_control active → {"data":{"branch":"true","result":true},"success":true} if_control inactive → {"data":{"branch":"false","result":false},"success":true} switch case1 → {"data":{"branch":"branch_active"},"success":true} switch case3 → {"data":{"branch":"branch_pending"},"success":true} switch 無匹配 → {"data":{"branch":"branch_default"},"success":true} try_catch 成功 → {"data":{"branch":"try","result":{"value":42}},"success":true} try_catch 失敗 → {"data":{"branch":"catch","error":"boom"},"success":true} ⇒ 三顆的標籤與 readBranch 讀的 data.branch **完全對得上**,非「理論上支援」。 新增 tests/branch-real-components.test.ts(8 項綠):把上列真輸出送進引擎, 驗 if 兩路/switch 多路(含**第 3 條 case** 證明第 N 路走對)+default/ try_catch ok 與 catch 兩路,每項都驗「該走的走、不該走的沒走」。 新增 tests/branch-hint-response.test.ts(7 項綠):驗逐顆查三顆的回應 自帶 branch_field/branches/edge_types/usage/example,並把 AI 實際看到的內容印出來 (總管要求「貼回應」)。另驗不分岔零件(http_request/code)無此欄=不加噪音。 全套 227 passed(前 212 +15),失敗數維持既有 9 筆未變;tsc --noEmit 綠。 SDD: workflow-discovery 3.11|CP: arcrun-usable 步驟 5 Co-Authored-By: Claude Fable 5 --- .../tests/branch-hint-response.test.ts | 68 ++++++++ .../tests/branch-real-components.test.ts | 147 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 cypher-executor/tests/branch-hint-response.test.ts create mode 100644 cypher-executor/tests/branch-real-components.test.ts diff --git a/cypher-executor/tests/branch-hint-response.test.ts b/cypher-executor/tests/branch-hint-response.test.ts new file mode 100644 index 0000000..44b2691 --- /dev/null +++ b/cypher-executor/tests/branch-hint-response.test.ts @@ -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(); + }); +}); diff --git a/cypher-executor/tests/branch-real-components.test.ts b/cypher-executor/tests/branch-real-components.test.ts new file mode 100644 index 0000000..3c2499b --- /dev/null +++ b/cypher-executor/tests/branch-real-components.test.ts @@ -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>, 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'); + }); +}); From a9a47b7c37427f3bebe49947d02229da36838c0d Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 31 Jul 2026 16:51:19 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=94=B4=20=E6=AD=A5=E9=A9=9F5=20?= =?UTF-8?q?=E8=A3=9C=E6=96=B7=E9=BB=9E=EF=BC=9A=E6=95=99=E6=9D=90=E8=AA=AA?= =?UTF-8?q?=E3=80=8C=E5=BC=95=E6=93=8E=E4=B8=8D=E6=94=AF=E6=8F=B4=E6=A2=9D?= =?UTF-8?q?=E4=BB=B6=E5=88=86=E6=94=AF=E3=80=8D=EF=BC=8B=E6=84=8F=E5=9C=96?= =?UTF-8?q?=E8=AA=9E=E6=B3=95=E6=94=B6=E4=B8=8D=E5=88=B0=E5=88=86=E6=94=AF?= =?UTF-8?q?=E6=A8=99=E7=B1=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 備考 haiku 真考時自查發現的兩個斷點——**若不補,考試必掛,且掛的是我自己的教材**。 形狀=步驟1 那個「世代脫節」的翻版:能力做好了,但教 AI 的地方還停在舊世代。 斷點①:三處教材主動說「沒有條件分支」,還教了正好造成腹語術的替代法 - mcp/src/mcp-handler.ts:44「**沒有** ON_TRUE/ON_FALSE——引擎目前不支援條件分支」 - registry/skills/write_intent_workflow.md:39「不要寫 ON_TRUE/ON_FALSE… 需要判斷時**寫成一個獨立節點**再接 ON_SUCCESS」← 這正是「判斷退回 code」的入口 - registry/skills/INDEX.md:44「已知的坑:引擎沒有條件分支」 ⇒ 三處全部更新成現況(三顆零件都輸出 data.branch、引擎依標籤選路、 查零件回應附 branch_hint 照著接即可),並保留「不要寫 ON_FAILURE」(那個真的沒有)。 斷點②(更隱蔽,靜默失效):`graph-builder` 只認得 `對每個 X` 的參數化 label, `ON_BRANCH(branch_active)` 帶括號會落到 toEdgeType 預設值 **PIPE** ⇒ 我在 skill 教的寫法,編圖收不到,而且**不報錯**——AI 以為分支了、實際全走同一條。 「教了語法但引擎不收」比沒做更糟,故與文件同批補上:比照 FOREACH 抽 iterator 的作法 抽 branch 標籤(半形/全形括號都收),寫進 edge.branch。 新增 tests/intent-branch-syntax.test.ts(7 項綠):守「文件教的寫法,編圖真的收得到」 ——ON_TRUE/ON_FALSE 不退化成 PIPE、中文「成立時/否則」、ON_BRANCH(標籤) 抽得出 branch、 全形括號、try/catch 標籤;零變化:ON_SUCCESS 仍是 ON_SUCCESS、對每個 X 的 iterator 不受干擾。 全套 234 passed(前 227 +7),失敗數維持既有 9 筆;tsc 綠。 SDD: workflow-discovery 3.11|CP: arcrun-usable 步驟 5 Co-Authored-By: Claude Fable 5 --- cypher-executor/src/actions/graph-builder.ts | 18 ++- .../tests/intent-branch-syntax.test.ts | 104 ++++++++++++++++++ mcp/src/mcp-handler.ts | 8 +- registry/skills/INDEX.md | 2 +- registry/skills/write_intent_workflow.md | 29 ++++- 5 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 cypher-executor/tests/intent-branch-syntax.test.ts diff --git a/cypher-executor/src/actions/graph-builder.ts b/cypher-executor/src/actions/graph-builder.ts index 3703b17..f37f96f 100644 --- a/cypher-executor/src/actions/graph-builder.ts +++ b/cypher-executor/src/actions/graph-builder.ts @@ -43,12 +43,28 @@ export function buildExecutionGraph( iterator = foreachMatch[1]; label = '對每個'; // 改回標準 label 走 SEMANTIC_EDGE_MAP } - const edge: { from: string; to: string; type: ReturnType; iterator?: string } = { + + // 「ON_BRANCH(標籤)」抽 branch:意圖語法表達具名分支(SDD workflow-discovery 3.11) + // 例:'my_switch >> ON_BRANCH(branch_active) >> 處理啟用' → type=ON_BRANCH, branch='branch_active' + // 沒有這段的話,帶括號的 label 會落到 toEdgeType 的預設值 PIPE ⇒ 分支靜默失效 + // (即「教了語法但引擎不收」——比沒做更糟,故與 skill 文件同批補上) + let branch: string | undefined; + const branchMatch = label.match(/^(?:ON_BRANCH|分支)\s*[((]\s*([\w-]+)\s*[))]$/i); + if (branchMatch) { + branch = branchMatch[1]; + label = 'ON_BRANCH'; + } + + const edge: { + from: string; to: string; type: ReturnType; + iterator?: string; branch?: string; + } = { from: e.from.toLowerCase().replace(/\s+/g, '-'), to: e.to.toLowerCase().replace(/\s+/g, '-'), type: toEdgeType(label), }; if (iterator) edge.iterator = iterator; + if (branch) edge.branch = branch; return edge; }); diff --git a/cypher-executor/tests/intent-branch-syntax.test.ts b/cypher-executor/tests/intent-branch-syntax.test.ts new file mode 100644 index 0000000..856a4ce --- /dev/null +++ b/cypher-executor/tests/intent-branch-syntax.test.ts @@ -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 }> = {}; + 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, 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(); + }); +}); diff --git a/mcp/src/mcp-handler.ts b/mcp/src/mcp-handler.ts index 02abf1d..e4e8a42 100644 --- a/mcp/src/mcp-handler.ts +++ b/mcp/src/mcp-handler.ts @@ -40,8 +40,12 @@ export async function handleMcpRequest( " 🔴 **不要因為查不到零件就改寫成 `code` 節點**——那叫「腹語術」(表面用 Arcrun、", " 實際全寫 JS)。`code` 只用於局部整形(例:剝掉 LLM 回應的雜訊)。", "", - "邊只有兩種:`ON_SUCCESS` 與 `對每個 <變數>`(FOREACH)。", - "**沒有** `ON_TRUE`/`ON_FALSE`/`ON_FAILURE`——引擎目前不支援條件分支。", + "邊:`ON_SUCCESS`(成功往下)、`對每個 <變數>`(FOREACH)、", + "以及**條件分支**(2026-08-01 起引擎支援):", + "`ON_TRUE`/`ON_FALSE`(配 `if_control`)、`ON_BRANCH`+`branch:` 標籤", + "(配 `switch` 的每個 case/`try_catch` 的 try·catch)。", + "🔴 **需要判斷時用分支邊,不要寫 code 判斷**——查零件的回應會附 `branch_hint`", + "(哪些邊型+可照抄範例),照著接即可。", "第一個節點固定是 `input`。", ].join("\n"); diff --git a/registry/skills/INDEX.md b/registry/skills/INDEX.md index e701006..6aad70c 100644 --- a/registry/skills/INDEX.md +++ b/registry/skills/INDEX.md @@ -41,7 +41,7 @@ | 坑 | 現況 | 怎麼避 | |---|---|---| | **`/cypher/search` 曾回假 `found`** | 2026-07-31 已修:兩庫(零件+recipe)都查,缺件回 `not_found`+`suggestion` 指路。舊實例(未更新部署)仍是假 found | 拿到 `not_found` 照 `suggestion` 走;拿到 `unknown`=查不到 registry ≠ 不存在 | -| **引擎沒有條件分支** | `grep ON_TRUE\|ON_FALSE` = 0;`if_control` 只回 boolean | 判斷寫成獨立節點接 `ON_SUCCESS`。見 Gitea Arcrun#5 | +| ~~引擎沒有條件分支~~ **已解(2026-08-01)** | 引擎支援 `ON_TRUE`/`ON_FALSE`/`ON_BRANCH`;`if_control`/`switch`/`try_catch` 都輸出 `data.branch` 標籤,引擎依標籤選路(Arcrun#5 根治)| **需要判斷就用分支邊,別寫 code 判斷**。查零件的回應附 `branch_hint`(邊型+可照抄範例),照著接 | | **`registry/examples/` 8/13 是壞的** | 引用不存在的零件(把 recipe 當零件寫)| **別照抄 examples**,改用 `arcrun_get_workflow` 拿實跑過的 | | **registry 可能是空的** | 安裝器無註冊步驟 ⇒ 新實例查不到零件 | 查不到 ≠ 不存在,別據此改寫成 code | diff --git a/registry/skills/write_intent_workflow.md b/registry/skills/write_intent_workflow.md index 1147f79..dea2e51 100644 --- a/registry/skills/write_intent_workflow.md +++ b/registry/skills/write_intent_workflow.md @@ -29,17 +29,36 @@ input >> ON_SUCCESS >> <第一步> >> ... → 丟 /cypher/search → 系統回 - **節點**=一個步驟。用你想得到的名字(中文可以),**不必是真實零件名** - **邊**=什麼情況下往下走 -## 2. 邊只有兩種(真範本裡出現過的) +## 2. 邊有這些 | 邊 | 意思 | 真例 | |---|---|---| | `ON_SUCCESS` | 上一步成功就往下 | `input >> ON_SUCCESS >> prep` | | `對每個 <變數>` | 上一步產出清單,逐項處理(FOREACH)| `parse_card >> 對每個 block >> post_block` | +| `ON_TRUE` / `ON_FALSE` | 條件成立/不成立各走一條(配 `if_control`)| `判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram` | +| `ON_BRANCH`+`branch:` | 依標籤選路(配 `switch` 每個 case、`try_catch` 的 try/catch)| `my_switch >> ON_BRANCH(branch_active) >> 處理啟用` | -⚠️ **不要寫 `ON_FAILURE`/`ON_TRUE`/`ON_FALSE`**——引擎目前**沒有條件分支** -(實測 `grep ON_TRUE|ON_FALSE` 於 cypher-executor = 0;見 Gitea Arcrun#5)。 -需要判斷時:**寫成一個獨立節點**(例 `check_amount`)再接 `ON_SUCCESS`, -讓查詢告訴你有沒有零件可用。 +### 2.1 條件分支怎麼寫(2026-08-01 起引擎支援) + +**需要判斷時,用分支邊,不要寫 `code` 判斷。** +三顆流程控制零件都輸出 `data.branch` 標籤,引擎依標籤選路: + +| 零件 | 輸出的標籤 | 接法 | +|---|---|---| +| `if_control` | `"true"` / `"false"` | `ON_TRUE`/`ON_FALSE` 各一條 | +| `switch` | 你在 `cases[].branch` 取的名字(沒中則 `default_branch`)| 每條路一條 `ON_BRANCH`,邊上標 `branch` | +| `try_catch` | `"try"`(沒錯)/`"catch"`(有錯)| 兩條 `ON_BRANCH`,標 `try` 與 `catch` | + +``` +判斷有沒有新資料 >> ON_TRUE >> 傳到 telegram +判斷有沒有新資料 >> ON_FALSE >> 結束 +``` +中文語意詞亦可:「成立時」=`ON_TRUE`、「否則」=`ON_FALSE`。 + +💡 **不必背**:查零件時回應會附 `branch_hint`(有哪些標籤、用哪些邊型、可照抄的範例), +照著接就對了。 + +⚠️ 仍然**不要寫 `ON_FAILURE`**(沒有這種邊;要處理失敗用 `try_catch` + `ON_BRANCH(catch)`)。 ## 3. 第一個節點固定是 `input`