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`