diff --git a/cypher-executor/src/actions/search-nodes.ts b/cypher-executor/src/actions/search-nodes.ts index a63af06..e10f0bf 100644 --- a/cypher-executor/src/actions/search-nodes.ts +++ b/cypher-executor/src/actions/search-nodes.ts @@ -1,20 +1,38 @@ import type { ParsedTriplets, NodeRole } from './triplet-parser'; -import { resolveNodeRole } from './triplet-parser'; +import { resolveNodeRole, isVirtualIoName } from './triplet-parser'; import { wasmWorkerUrl } from '../lib/component-loader'; +import { resolveRecipe } from '../routes/recipes'; +import type { RecipeDefinition } from '../routes/recipes'; -export type NodeStatus = 'found' | 'missing' | 'unknown'; +/** + * `not_found` 而非 `missing`:欄位契約以頂層機械考 + * `system-dev/docs/3-specs/arcrun-usable/verify.sh` 為準(01 組 grep `not_found`)。 + */ +export type NodeStatus = 'found' | 'not_found' | 'unknown'; export type NodeInfo = { status: NodeStatus; componentId?: string; type: NodeRole; + /** found 時標來源庫:零件 registry(component)或 recipe 庫(recipe)。 */ + source?: 'component' | 'recipe'; /** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */ input_schema?: unknown; /** 成功率(found 時附上,讓「被測過幾次」看得見)。 */ success_rate?: number; stability?: string; - /** missing 時給的相近零件建議(避免 AI 只知道「沒有」卻不知道該用什麼)。 */ - suggestions?: string[]; + /** recipe found 時附上(AI 看得懂這個 recipe 在打哪個 API)。 */ + description?: string; + endpoint?: string; + /** + * not_found 時的分型指路(task 3.7):兩庫(零件 registry+recipe 庫)都查過才點名, + * 並告訴 AI 該走哪條補件路+去哪裡看做法。欄位名 `suggestion`(單數字串)=verify.sh 03 組契約。 + */ + suggestion?: string; + /** not_found 時的相近零件候選(自然語言節點名 → 既有零件的媒合)。 */ + similar_components?: string[]; + /** not_found 時的相近 recipe 候選。 */ + similar_recipes?: string[]; }; export type SearchResult = { @@ -22,13 +40,26 @@ export type SearchResult = { missingNodes: string[]; }; +/** searchNodes 需要的環境子集(cypher-handlers 傳整份 Bindings 進來也相容)。 */ +export type SearchNodesEnv = { + WORKER_SUBDOMAIN?: string; + /** + * registry 位置覆蓋(可選,非機密)。未設 → 用 wasmWorkerUrl('registry', WORKER_SUBDOMAIN) + * 現算(比照 KBDB_GRAPH_URL 慣例)。本地 wrangler dev / self-hosted 把 registry 掛別處時用。 + */ + REGISTRY_BASE_URL?: string; + /** recipe 庫(本 worker 自己的 KV;task 3.6 兩庫都查的第二庫)。 */ + RECIPES?: KVNamespace; +}; + /** - * 對所有節點進行解析,確認每個節點對應的零件 ID 與**是否真的存在**。 + * 對所有節點進行解析,確認每個節點對應的零件/recipe 是否**真的存在**。 * - * ⚠️ 2026-07-30 改為會查 registry(workflow-discovery task 3.x)。 + * ⚠️ 2026-07-30 改為會查 registry(workflow-discovery task 3.x); + * 2026-07-31 再加 recipe 庫查詢+缺件分型指路(task 3.6/3.7)。 * * 改之前的行為(病灶):無條件回 `status: 'found'`、`missingNodes` 永遠是空陣列—— - * 型別雖宣告了 `'missing'` 但程式碼從不使用。實測「完全不存在的東西xyz」也回 found。 + * 實測「完全不存在的東西xyz」也回 found。 * * 為什麼這是嚴重問題(leo 2026-07-30 定性「腹語術」): * AI 寫意圖 → 查詢回「都 found」(假信號)→ 實際零件不存在 @@ -36,23 +67,32 @@ export type SearchResult = { * → 於是正式 workflow 只用 2 個零件、8 個 code 節點含 if×61 * ⇒ 「零件被測過 1000 次所以 AI 只要填 payload」的價值完全落空。 * - * 誠實限制:查不到 registry(未部署/網路失敗)時回 `'unknown'` 而不是 `'missing'`—— + * 設計基調(leo 2026-07-31 二次定調):**回覆的重點是「缺哪些」不是「有哪些」**—— + * 有的照常編圖不必報告;缺的要兩庫(零件 registry+recipe 庫)都搜過後點名+給正確指示: + * 缺外部 API → 自己寫 recipe(skill `write_recipe`); + * 缺計算原語 → 投稿零件 PR(skill `add_new_wasm_component`)。 + * + * 誠實限制:查不到 registry(未部署/網路失敗)時回 `'unknown'` 而不是 `'not_found'`—— * 不能因為查詢失敗就宣告零件不存在(那會讓 AI 誤判而重寫 code,正是要避免的事)。 */ export async function searchNodes( parsed: ParsedTriplets, config?: Record>, - env?: { WORKER_SUBDOMAIN?: string }, + env?: SearchNodesEnv, ): Promise { const nodeResults: Record = {}; const missingNodes: string[] = []; const sub = env?.WORKER_SUBDOMAIN; + const registryBase = env?.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined); for (const nodeName of parsed.nodeNames) { const role = resolveNodeRole(nodeName, parsed); - if (role === 'Input' || role === 'Output') { + // 只有**字面上的虛擬 IO 名**(input/trigger/…/output/done)才免查—— + // 位置上是頭節點但名字是真零件(`aes_encrypt >> … >> code` 的頭,role 也是 Input) + // 仍要照常查兩庫,否則缺件被角色掩蓋、又回到「假 found」。 + if ((role === 'Input' || role === 'Output') && isVirtualIoName(nodeName)) { nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role }; continue; } @@ -61,21 +101,22 @@ export async function searchNodes( const componentId = configComponent ?? nodeName; // config 明確給了 component(多半是安裝器代入的 worker URL 或既有 workflow) - // → 不判 missing。這條路徑的存在性由 component-loader 在執行時決定(原行為)。 + // → 不判 not_found。這條路徑的存在性由 component-loader 在執行時決定(原行為)。 if (configComponent) { nodeResults[nodeName] = { status: 'found', componentId, type: role }; continue; } - if (!sub) { + if (!registryBase) { nodeResults[nodeName] = { status: 'unknown', componentId, type: role }; continue; } - const q = await fetchComponent(sub, componentId); + // ── 第一庫:零件 registry ──────────────────────────────────────────────── + const q = await fetchComponent(registryBase, componentId); if (!q.ok) { // registry 查不通(未部署/網路失敗)⇒ 誠實回 unknown。 - // **不能誤判 missing**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。 + // **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。 nodeResults[nodeName] = { status: 'unknown', componentId, type: role }; continue; } @@ -84,6 +125,7 @@ export async function searchNodes( status: 'found', componentId, type: role, + source: 'component', input_schema: q.entry.input_schema, success_rate: q.entry.success_rate, stability: q.entry.stability, @@ -91,13 +133,99 @@ export async function searchNodes( continue; } - nodeResults[nodeName] = { status: 'missing', componentId, type: role }; + // ── 第二庫:recipe 庫(task 3.6——「說不出某 recipe 沒有」的病灶就在漏了這步)── + const recipe = env?.RECIPES ? await resolveRecipe(componentId, env.RECIPES) : null; + if (recipe) { + nodeResults[nodeName] = { + status: 'found', + componentId: recipe.canonical_id, + type: role, + source: 'recipe', + description: recipe.description, + endpoint: recipe.endpoint, + }; + continue; + } + + // ── 兩庫都沒有 ⇒ not_found + 分型指路(task 3.7)+ 相近候選 ──────────── + const [similarComponents, similarRecipes] = await Promise.all([ + searchSimilarComponents(registryBase, nodeName), + env?.RECIPES ? searchSimilarRecipes(env.RECIPES, nodeName) : Promise.resolve([]), + ]); + + nodeResults[nodeName] = { + status: 'not_found', + componentId, + type: role, + suggestion: buildSuggestion(componentId), + ...(similarComponents.length > 0 ? { similar_components: similarComponents } : {}), + ...(similarRecipes.length > 0 ? { similar_recipes: similarRecipes } : {}), + }; missingNodes.push(nodeName); } return { nodeResults, missingNodes }; } +// ── 缺件分型(task 3.7)──────────────────────────────────────────────────────── +// +// 分型判準(刻意用簡單可解釋的規則,不接 LLM——查詢端點要快、要可預測): +// 1) 名字含**外部服務詞**(google/telegram/slack…)→「外部 API 樣貌」 +// → recipe 路:recipe 是 http_request+參數模板的具名封裝,用戶自己就能寫,不用改平台。 +// 2) 否則名字含**計算原語詞**(encrypt/hash/encode…)→「計算原語樣貌」 +// → 零件路:純計算得進 WASM 沙箱跑,要走 GitHub PR 投稿(人 merge=人類閘門,mindset §4)。 +// 3) 都不含 → 判不出型,誠實說判不出,兩條路都給(不硬猜——猜錯會把人指去錯的路)。 +// 判斷順序:服務詞優先於計算詞——「google_sheets_parse」雖含 parse,本質仍是打外部 API。 + +const SERVICE_HINTS = [ + 'google', 'gmail', 'sheets', 'slides', 'gdocs', 'drive', 'calendar', 'youtube', + 'slack', 'telegram', 'discord', 'line', 'whatsapp', 'twilio', + 'notion', 'airtable', 'trello', 'jira', 'asana', 'linear', + 'github', 'gitea', 'gitlab', 'bitbucket', + 'stripe', 'paypal', 'shopify', 'hubspot', 'salesforce', + 'openai', 'anthropic', 'claude', 'gemini', 'groq', + 'twitter', 'facebook', 'instagram', 'linkedin', 'dropbox', 'zoom', + 'sendgrid', 'mailgun', 'kbdb', +]; + +const COMPUTE_HINTS = [ + 'encrypt', 'decrypt', 'cipher', 'aes', 'rsa', 'sha', 'md5', 'hmac', 'hash', + 'sign', 'verify', 'encode', 'decode', 'base64', 'hex', + 'compress', 'decompress', 'zip', 'gzip', + 'uuid', 'random', 'regex', 'math', 'calc', + 'sort', 'dedup', 'diff', 'template', 'render', 'convert', 'transform', + 'parse', 'format', 'csv', 'xml', +]; + +function buildSuggestion(componentId: string): string { + const lower = componentId.toLowerCase(); + const serviceHit = SERVICE_HINTS.find(w => lower.includes(w)); + const computeHit = COMPUTE_HINTS.find(w => lower.includes(w)); + + if (serviceHit) { + return ( + `兩庫都查過,零件 registry 與 recipe 庫皆無「${componentId}」。` + + `名字含服務詞「${serviceHit}」=外部 API 樣貌 → 沒有此 recipe,可自己寫:` + + `寫法看 skill「write_recipe」(arcrun_get_skill('write_recipe')),` + + `寫好用 acr recipe push 或 POST /recipes 裝上即可用,不用改平台。` + ); + } + if (computeHit) { + return ( + `兩庫都查過,零件 registry 與 recipe 庫皆無「${componentId}」。` + + `名字含計算詞「${computeHit}」=計算原語樣貌 → 沒有此零件,可投稿 PR 新增 WASM component:` + + `做法看 skill「add_new_wasm_component」(arcrun_get_skill('add_new_wasm_component'))。` + ); + } + return ( + `兩庫都查過,零件 registry 與 recipe 庫皆無「${componentId}」,且名字判不出型。` + + `缺外部 API → 自己寫 recipe(skill「write_recipe」);` + + `缺計算能力 → 投稿零件 PR(skill「add_new_wasm_component」,component 進 WASM 沙箱)。` + ); +} + +// ── registry 查詢 ───────────────────────────────────────────────────────────── + type CatalogEntry = { input_schema?: unknown; success_rate?: number; @@ -112,16 +240,15 @@ type CatalogEntry = { * 這是 CP2-B 記載的缺口(「修 /components 404」)——補了列表端點後可改為抓一次。 * 現階段逐個查:節點數通常 <10,且有 5s timeout,可接受。 * - * 回傳 `null` 代表「查不到 registry 或該零件不存在」,由呼叫端區分: - * 整體查不通 → `unknown`;查得通但這顆沒有 → `missing`。 + * 回傳 `ok:false` 代表「查不到 registry」,由呼叫端區分: + * 整體查不通 → `unknown`;查得通但這顆沒有 → 繼續查 recipe 庫。 */ async function fetchComponent( - subdomain: string, + registryBase: string, id: string, ): Promise<{ ok: boolean; entry?: CatalogEntry }> { try { - const base = wasmWorkerUrl('registry', subdomain); - const res = await fetch(`${base}/components/${encodeURIComponent(id)}`, { + const res = await fetch(`${registryBase}/components/${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(5000), }); if (res.status === 404) return { ok: true }; // registry 活著,但沒這顆 @@ -141,3 +268,76 @@ async function fetchComponent( return { ok: false }; } } + +// ── 相近候選(自然語言節點名 → 既有零件/recipe 的媒合)────────────────────────── +// +// 節點名常是自然語言(例「判斷有沒有新資料」)。leo:「AI 不用知道零件存在」—— +// 所以 not_found 時要主動給相近候選,讓 AI 看回覆就知道「其實有 if_control 可用」。 +// 做法:先拿全名打 registry `/components/search`;沒中再斷詞重試—— +// ASCII 取 3 字以上的詞、中日韓取 2-gram(registry search 是子字串比對,整句中文必落空, +// 2-gram 才撈得到「判斷」→ if_control(display_name「條件判斷」)這種命中)。 + +function extractTokens(name: string): string[] { + const tokens: string[] = []; + const ascii = name.toLowerCase().match(/[a-z0-9]{3,}/g) ?? []; + tokens.push(...ascii); + const cjkRuns = name.match(/[一-鿿]+/g) ?? []; + for (const run of cjkRuns) { + for (let i = 0; i + 2 <= run.length; i++) tokens.push(run.slice(i, i + 2)); + } + return [...new Set(tokens)].slice(0, 8); // 上限 8 個 token,避免對 registry 掃太多輪 +} + +async function searchRegistryIds(registryBase: string, q: string): Promise { + try { + const res = await fetch(`${registryBase}/components/search?q=${encodeURIComponent(q)}`, { + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return []; + const body = (await res.json()) as { data?: { results?: Array<{ canonical_id?: string }> } }; + return (body.data?.results ?? []).map(r => r.canonical_id).filter((s): s is string => !!s); + } catch { + return []; + } +} + +async function searchSimilarComponents(registryBase: string, nodeName: string): Promise { + // 1) 全名直接搜 + const direct = await searchRegistryIds(registryBase, nodeName); + if (direct.length > 0) return direct.slice(0, 3); + + // 2) 斷詞搜,依命中次數排序 + const tokens = extractTokens(nodeName); + if (tokens.length === 0) return []; + const hits = await Promise.all(tokens.map(t => searchRegistryIds(registryBase, t))); + const count = new Map(); + for (const ids of hits) { + for (const id of ids) count.set(id, (count.get(id) ?? 0) + 1); + } + return [...count.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id]) => id); +} + +/** recipe 庫的相近候選:KV 全列(本部署 recipe 數量小)後子字串比對。 */ +async function searchSimilarRecipes(kv: KVNamespace, nodeName: string): Promise { + try { + const list = await kv.list({ prefix: 'recipe:' }); + const all = (await Promise.all( + list.keys.map(k => kv.get(k.name, 'json') as Promise), + )).filter(Boolean) as RecipeDefinition[]; + + const tokens = [nodeName.toLowerCase(), ...extractTokens(nodeName)]; + const seen = new Set(); + const matched: string[] = []; + for (const r of all) { + if (seen.has(r.canonical_id)) continue; + const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase(); + if (tokens.some(t => hay.includes(t))) { + seen.add(r.canonical_id); + matched.push(r.canonical_id); + } + } + return matched.slice(0, 3); + } catch { + return []; + } +} diff --git a/cypher-executor/src/actions/triplet-parser.ts b/cypher-executor/src/actions/triplet-parser.ts index 703cf42..de7ae43 100644 --- a/cypher-executor/src/actions/triplet-parser.ts +++ b/cypher-executor/src/actions/triplet-parser.ts @@ -105,6 +105,17 @@ export function parseTriplets(rawTriplets: unknown[]): ParsedTriplets | null { const INPUT_NAMES = new Set(['input', 'trigger', 'webhook', 'start']); const OUTPUT_NAMES = new Set(['output', 'result', 'end', 'done']); +/** + * 是否為「虛擬 IO 節點名」(input/output 這類非零件的佔位節點)。 + * searchNodes 用它決定存在性查詢的短路:**只有字面上是虛擬 IO 名**才免查—— + * 位置上是頭節點但名字是真零件(例 `aes_encrypt >> ON_SUCCESS >> code` 的頭) + * 仍要查兩庫,否則缺件被角色掩蓋、又回到「假 found」(task 3.7 實測踩到)。 + */ +export function isVirtualIoName(name: string): boolean { + const lower = name.toLowerCase(); + return INPUT_NAMES.has(lower) || OUTPUT_NAMES.has(lower); +} + /** 根據節點在圖中的位置決定其 type * * 規則: diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 742faa4..94efb72 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -124,6 +124,10 @@ export type Bindings = { // 未設 → 純文字顯示,行為與現狀一字不變。知識庫 repo 是 private 時點了會要登入——要不要 // 設由實例自己決定(demo 知識庫是 public,適用)。 PORTAL_SOURCE_WEB_BASE?: string; + // 零件 registry worker base URL(可選,非機密)。未設 → 用 WORKER_SUBDOMAIN 現算 + // https://arcrun-registry..workers.dev(wasmWorkerUrl 慣例)。 + // 本地 wrangler dev/self-hosted 把 registry 掛別處時覆蓋(/cypher/search 存在性查詢用)。 + REGISTRY_BASE_URL?: string; // kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算 // https://kbdb-graph-plugin..workers.dev(該 repo wrangler.toml name 固定)。 // console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。 diff --git a/registry/src/actions/queryComponents.ts b/registry/src/actions/queryComponents.ts index a830568..b6dbdfb 100644 --- a/registry/src/actions/queryComponents.ts +++ b/registry/src/actions/queryComponents.ts @@ -22,6 +22,10 @@ export interface ComponentRecord { call_count: number; wasm_r2_key?: string; score: number; + // 零件合約的 I/O schema。KV 記錄一直有存(indexOnlyComponent 寫入), + // 過去查詢層把它丟掉 ⇒ /cypher/search 給不出「怎麼填 payload」——2026-07-31 補透傳。 + input_schema?: Record; + output_schema?: Record; } // ── id 解析:支援 hash_id 和 canonical_id 兩種格式 ────────────────────────── @@ -158,5 +162,11 @@ function toComponentRecord(v: Record): ComponentRecord { call_count: parseInt(String(v.call_count ?? '0'), 10), wasm_r2_key: v.wasm_r2_key ? String(v.wasm_r2_key) : undefined, score: computeScore(v), + input_schema: isPlainObject(v.input_schema) ? v.input_schema : undefined, + output_schema: isPlainObject(v.output_schema) ? v.output_schema : undefined, }; } + +function isPlainObject(x: unknown): x is Record { + return typeof x === 'object' && x !== null && !Array.isArray(x); +} diff --git a/system-dev/docs/3-specs/workflow-discovery/tasks.md b/system-dev/docs/3-specs/workflow-discovery/tasks.md index f5e9196..c123710 100644 --- a/system-dev/docs/3-specs/workflow-discovery/tasks.md +++ b/system-dev/docs/3-specs/workflow-discovery/tasks.md @@ -61,13 +61,24 @@ > 機械考題已在頂層 `arcrun-usable/verify.sh` 03 組(aes_encrypt/google_slides_create 兩題, > 現對實例跑全紅=正確標記)。**行為綠過才准佔用 arm 部署**(頂層 mistakes 07-31 條)。 -- [ ] 3.6 recipe 納入 `/cypher/search` 存在性查詢(現只查零件 registry ⇒ 說不出「某 recipe 沒有」) -- [ ] 3.7 缺件回應分型指路:`suggestion` 欄——計算原語型→「投稿零件 PR」;外部 API 型→「自己寫 recipe」 +- [x] 3.6 recipe 納入 `/cypher/search` 存在性查詢(現只查零件 registry ⇒ 說不出「某 recipe 沒有」) + — 2026-07-31 search-nodes.ts:registry 落空後查 RECIPES KV(resolveRecipe), + recipe found 附 source:'recipe'+description+endpoint;本地實測 telegram_send found ✓ +- [x] 3.7 缺件回應分型指路:`suggestion` 欄——計算原語型→「投稿零件 PR」;外部 API 型→「自己寫 recipe」 (欄位契約定案時同步頂層 verify.sh 03 組的 grep) **且每筆要帶「去哪裡看」**(leo 07-31 三次定調):component 路→skill `add_new_wasm_component` (已存在、安裝器現會 seed 進新實例);recipe 路→skill `write_recipe`(**不存在,見 3.8**) -- [ ] 3.8 寫 `write_recipe` skill(registry/skills/)——「怎麼寫 recipe」的 playbook 目前是空地, + — 2026-07-31 完成:status 契約定 `not_found`(同 verify.sh 01/03 grep);分型=服務詞/計算詞 + 簡單規則(不接 LLM,規則在 code 註解);附 similar_components/similar_recipes 相近候選 + (自然語言「判斷有沒有新資料」媒合到 if_control)。順手修二病灶:①頭節點被 resolveNodeRole + 判 Input 而免查(`aes_encrypt >> … >> code` 假 found)→ 只有字面 input/output 名才短路; + ② registry 查詢層把 KV 裡的 input_schema 丟掉 → 補透傳。本地 wrangler dev 跑頂層 + verify.sh 01+03 組 9/9 全綠(部署後要對真實例重驗) +- [x] 3.8 寫 `write_recipe` skill(registry/skills/)——「怎麼寫 recipe」的 playbook 目前是空地, 3.7 的 recipe 指路沒有目的地;寫完納入安裝器 seed 清單(compile-skills.mjs 自動收) + — 2026-07-31 完成:內容從真 code 反推(RecipeDefinition schema/api-recipe-seeds 的 + telegram_send+gmail_send 真範例/auth-recipes 必填欄位/acr recipe push 打通檢查), + 比照 write_intent_workflow 風格;INDEX.md+write_intent_workflow.md 同步指路 not_found - 設計基調(leo 07-31 二次定調):**回覆的重點是「缺哪些」不是「有哪些」**—— 有的照常編圖不必報告;缺的要兩庫(零件+recipe)都搜過後點名+給正確指示。 驗收兩層:機械(verify 01/03)綠 → haiku 真考(只讀回覆就能說出缺什麼、該做什麼)。