From d48f83ae6fcc5d9172a56085059d508035854c90 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Fri, 31 Jul 2026 13:49:09 +0800 Subject: [PATCH] =?UTF-8?q?t159=20=E6=AD=A5=E9=A9=9F4=20=E6=84=8F=E5=9C=96?= =?UTF-8?q?=E7=AF=80=E9=BB=9E=E2=86=92=E7=9C=9F=E5=AF=A6=E9=9B=B6=E4=BB=B6?= =?UTF-8?q?/recipe=20=E6=9B=BF=E6=8F=9B=EF=BC=8B/cypher/search=20=E5=8A=A0?= =?UTF-8?q?=20target=20=E6=8C=87=E5=AE=9A=E6=90=9C=E5=B0=8B=E5=B0=8D?= =?UTF-8?q?=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CP arcrun-usable 步驟 4(目的:AI 只要填 payload——系統把「傳到 telegram」 翻成 http_request+recipe telegram_send)+leo 07-31 追加: 「難道我不能指定要搜尋工作流或節點或 recipe 嗎?」 替換(只動 discover,t158「部署≠發現」邊界不碰): - 兩庫 exact 落空後,在 t158 一次抓好的清單記憶體內媒合,零新增 round-trip - 規則 A 服務詞→recipe:名字全部服務詞命中同一 recipe 且唯一才換 (google_slides_create 不被 google_sheets 誤吃) - 規則 B 強欄位斷詞→零件:canonical/display/aliases 強命中×10+弱命中, 需至少一強命中且分數唯一最高(aes_encrypt 無強命中不換) - 換到=status resolved+substitution{from,componentId,recipe,reason}, cypher 圖節點直接帶真實 componentId;換不到照舊 not_found+3.7 指路 target 參數(各走既有機制,不新造第二套搜尋): - triplets+target=component|recipe=只查該庫 - query+target=名字搜尋:component→registry /components/search(=MCP arcrun_search_components 同路);recipe→私庫 RECIPES KV(回應註明公庫走 arcrun_recipe_search);workflow→新抽 lib/workflow-search.ts, GET /workflows/search 與 target=workflow 共用(=arcrun_search_workflows 同路) - 防呆:compile+target 400/target=workflow 吃 query 不吃 triplets/非法 target 400 驗(本地 wrangler dev,registry 種 20 合約+init/seed 10 recipe): - 「判斷有沒有新資料 >> ON_SUCCESS >> 傳到 telegram」→ if_control(resolved) +telegram_send(substitution.componentId=http_request)=feature 06 驗法過 - 機械考 27/27 全綠(01 組×5+03 組×4 迴歸+06 組×8+target×8+compile 迴歸×2) - 冷啟第一發 94ms、熱 8–13ms(t158 病史對照:舊 25.7s);compile 39ms unchecked 照舊 - tsc 全綠;vitest 9 failed/179 passed=t158 基線完全相同 Co-Authored-By: Claude Fable 5 --- .../src/actions/cypher-handlers.ts | 5 +- cypher-executor/src/actions/search-nodes.ts | 158 +++++++++++++++++- cypher-executor/src/actions/target-search.ts | 98 +++++++++++ cypher-executor/src/lib/workflow-search.ts | 49 ++++++ cypher-executor/src/routes/cypher.ts | 41 ++++- cypher-executor/src/routes/webhooks-named.ts | 14 +- .../docs/3-specs/workflow-discovery/tasks.md | 29 ++++ 7 files changed, 374 insertions(+), 20 deletions(-) create mode 100644 cypher-executor/src/actions/target-search.ts create mode 100644 cypher-executor/src/lib/workflow-search.ts diff --git a/cypher-executor/src/actions/cypher-handlers.ts b/cypher-executor/src/actions/cypher-handlers.ts index 05de7f6..cf3af39 100644 --- a/cypher-executor/src/actions/cypher-handlers.ts +++ b/cypher-executor/src/actions/cypher-handlers.ts @@ -5,13 +5,14 @@ import { graphSchema } from '../lib/schemas'; import { createComponentLoader } from '../lib/component-loader'; import { writeEvaluation, updateComponentStats } from './execution-evaluator'; import { parseTriplets } from './triplet-parser'; -import { searchNodes, type SearchMode } from './search-nodes'; +import { searchNodes, type SearchMode, type SearchTarget } from './search-nodes'; import { buildExecutionGraph } from './graph-builder'; export async function handleCypherSearch( triplets: unknown[], env: Bindings, mode: SearchMode = 'discover', + target?: SearchTarget, ): Promise<{ nodes: Record; cypher: unknown; missing: string[] }> { const parsed = parseTriplets(triplets); if (!parsed) { @@ -25,7 +26,7 @@ export async function handleCypherSearch( // 誠實化只屬於 **discover**(AI 問「有沒有」);**compile**(部署/推送的複製路徑) // 純編圖零查詢——那本來就是既有設計(workflows.json=打包期預編的搬運), // 5cadc60 起誠實化漏進複製路徑=迴歸(冷實例 8 節點 25.7s、安裝器 timeout 炸)。 - const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env, mode); + const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env, mode, target); const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result'); return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: missingNodes }; diff --git a/cypher-executor/src/actions/search-nodes.ts b/cypher-executor/src/actions/search-nodes.ts index a818b65..1dc8133 100644 --- a/cypher-executor/src/actions/search-nodes.ts +++ b/cypher-executor/src/actions/search-nodes.ts @@ -9,7 +9,34 @@ import type { RecipeDefinition } from '../routes/recipes'; * `system-dev/docs/3-specs/arcrun-usable/verify.sh` 為準(01 組 grep `not_found`)。 */ /** `unchecked`=compile 模式的誠實標記:沒查、不知道有沒有(≠found 的假信號)。 */ -export type NodeStatus = 'found' | 'not_found' | 'unknown' | 'unchecked'; +/** `resolved`=意圖節點被媒合替換成真實零件/recipe(步驟 4;≠字面 exact 的 found)。 */ +export type NodeStatus = 'found' | 'not_found' | 'unknown' | 'unchecked' | 'resolved'; + +/** + * 意圖節點 → 真實零件/recipe 的替換結果(CP 步驟 4,workflow-discovery 3.x 搜尋端延伸)。 + * 目的(CP 原文):AI 只要填 payload——系統把「傳到 telegram」翻成 + * `http_request`+recipe `telegram_send`,並明說缺什麼。 + */ +export type NodeSubstitution = { + /** 原始意圖節點名(替換前)。 */ + from: string; + /** + * 執行底層零件:component 替換=該零件本身; + * recipe 替換=`http_request`(recipe 是 http_request+參數模板的具名封裝)。 + */ + componentId: string; + /** recipe 替換時的 canonical_id——workflow config 寫 `component: <此值>` 即可直接用。 */ + recipe?: string; + /** 為什麼這樣換(簡單可解釋規則的命中說明,不接 LLM)。 */ + reason: string; +}; + +/** + * 指定搜尋對象(leo 07-31:「難道我不能指定要搜尋工作流或節點或 recipe 嗎?」)。 + * 不給=現行混搜兩庫+意圖替換;`component`=只查零件 registry;`recipe`=只查 recipe 庫。 + * `workflow` 不進本函式——workflow 搜尋是名字搜尋(route 層走既有 /workflows/search 機制)。 + */ +export type SearchTarget = 'component' | 'recipe'; export type NodeInfo = { status: NodeStatus; @@ -34,6 +61,8 @@ export type NodeInfo = { similar_components?: string[]; /** not_found 時的相近 recipe 候選。 */ similar_recipes?: string[]; + /** resolved 時的替換明細(步驟 4:意圖節點 → 真實零件/recipe)。 */ + substitution?: NodeSubstitution; }; export type SearchResult = { @@ -93,6 +122,7 @@ export async function searchNodes( config?: Record>, env?: SearchNodesEnv, mode: SearchMode = 'discover', + target?: SearchTarget, ): Promise { const nodeResults: Record = {}; const missingNodes: string[] = []; @@ -119,12 +149,20 @@ export async function searchNodes( const sub = env?.WORKER_SUBDOMAIN; const registryBase = env?.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined); + // target 限庫(leo 07-31):component=只查零件 registry;recipe=只查 recipe 庫。 + // 不給=混搜兩庫(既有行為)。 + const wantComponents = target !== 'recipe'; + const wantRecipes = target !== 'component'; + // ── discover 批次化(t158):兩庫各抓**一次**,之後全在記憶體內比對。──────── // 病史(07-31 stage 實測):舊版對每個 missing 節點各打「1 次逐顆查+最多 9 次 // 相似搜尋+一輪 recipe KV 掃描」⇒ 冷實例 8 節點 /cypher/search 25.7s, // 安裝器 15s timeout 必炸。批次化後每 request 固定 1 次 catalog+1 次 recipe 清單。 - const catalog = registryBase ? await fetchCatalog(registryBase) : { status: 'unreachable' as const, entries: [] }; - const recipes = env?.RECIPES ? await listAllRecipes(env.RECIPES) : []; + // 步驟 4 的意圖替換也在**同一份清單**上做——不加任何新 round-trip。 + const catalog = !wantComponents + ? { status: 'ok' as const, entries: [] } // target=recipe:registry 不參與,不因此回 unknown + : registryBase ? await fetchCatalog(registryBase) : { status: 'unreachable' as const, entries: [] }; + const recipes = wantRecipes && env?.RECIPES ? await listAllRecipes(env.RECIPES) : []; const byId = new Map(); for (const e of catalog.entries) { const prev = byId.get(e.canonical_id); @@ -196,6 +234,15 @@ export async function searchNodes( continue; } + // ── 步驟 4:意圖節點 → 真實零件/recipe 替換(同一份清單、全記憶體)──────── + // 字面 exact 兩庫都落空的自然語言節點(例「傳到 telegram」「判斷有沒有新資料」), + // 先試保守的替換規則;換得到=resolved(回應直接可組 workflow),換不到才 not_found。 + const substituted = trySubstitution(nodeName, catalog.entries, recipes); + if (substituted) { + nodeResults[nodeName] = { ...substituted, type: role }; + continue; + } + // ── 兩庫都沒有 ⇒ not_found + 分型指路(task 3.7)+ 相近候選(全記憶體)── const similarComponents = similarFromCatalog(catalog.entries, nodeName); const similarRecipes = similarFromRecipes(recipes, nodeName); @@ -243,8 +290,9 @@ async function fetchCatalog(registryBase: string): Promise { } } -/** 一次抓 recipe 全清單(本部署 recipe 數量小;exact 與相似度共用同一份)。 */ -async function listAllRecipes(kv: KVNamespace): Promise { +/** 一次抓 recipe 全清單(本部署 recipe 數量小;exact 與相似度共用同一份)。 + * export 給 target=recipe 的名字搜尋(actions/target-search.ts)共用同一份讀法。 */ +export async function listAllRecipes(kv: KVNamespace): Promise { try { const list = await kv.list({ prefix: 'recipe:' }); return (await Promise.all( @@ -334,6 +382,106 @@ async function legacyPerNodeLookup( }; } +// ── 步驟 4:意圖節點 → 真實零件/recipe 替換 ──────────────────────────────────── +// +// 目的(CP arcrun-usable 步驟 4):AI 只要填 payload——系統把「傳到 telegram」翻成 +// `http_request`+recipe `telegram_send`。媒合在「一次抓好的兩庫清單」記憶體內做, +// 零新增 round-trip;規則沿用 task 3.7 的服務詞判型+既有斷詞媒合(extractTokens), +// 刻意簡單可解釋、不接 LLM。 +// +// 兩條規則(保守——換錯比不換更糟,寧可 not_found+候選讓 AI 自己選): +// A) 服務詞規則(recipe 路):節點名含 SERVICE_HINTS 服務詞 → 名字裡**全部**服務詞 +// 都命中同一個 recipe、且該 recipe **唯一**才替換。 +// 例「傳到 telegram」:服務詞 [telegram] → 唯一命中 telegram_send ⇒ 換。 +// 反例「google_slides_create」:服務詞 [google, slides] → google_sheets_* 只中 +// google 不中 slides ⇒ 不換(照 3.7 指去寫 recipe)。 +// 有服務詞的節點**不落入規則 B**——外部服務就該是 recipe,不硬配零件 +// (否則「google_slides」會被 display_name 含 Google 的零件誤吃)。 +// B) 強欄位規則(零件路):斷詞後只算**強欄位**(canonical_id/display_name/aliases) +// 命中為主:分數=強命中×10+弱命中(description/tags)×1, +// 需「至少一個強命中」且「分數唯一最高」才替換。 +// 例「判斷有沒有新資料」:2-gram「判斷」命中 if_control display_name「條件判斷」 +// (強 10 分),try_catch 只在 description 中「判斷」(弱 1 分)⇒ 唯一最高 ⇒ 換。 +// 反例「aes_encrypt」:無任何強命中 ⇒ 不換(照 3.7 指去投零件 PR)。 + +type SubstitutionHit = Pick< + NodeInfo, + 'status' | 'componentId' | 'source' | 'substitution' | + 'input_schema' | 'success_rate' | 'stability' | 'description' | 'endpoint' +>; + +function trySubstitution( + nodeName: string, + catalogEntries: CatalogFullRecord[], + recipes: RecipeDefinition[], +): SubstitutionHit | null { + const lower = nodeName.toLowerCase(); + const serviceHits = SERVICE_HINTS.filter(w => lower.includes(w)); + + // 規則 A:服務詞 → recipe(全部服務詞命中+唯一) + if (serviceHits.length > 0) { + const matched = new Map(); + for (const r of recipes) { + const hay = `${r.canonical_id} ${r.display_name ?? ''} ${r.description ?? ''}`.toLowerCase(); + if (serviceHits.every(h => hay.includes(h))) matched.set(r.canonical_id, r); + } + if (matched.size !== 1) return null; // 0=真缺件走 not_found;≥2=歧義,候選留給 similar_recipes + const recipe = [...matched.values()][0]; + return { + status: 'resolved', + componentId: recipe.canonical_id, + source: 'recipe', + description: recipe.description, + endpoint: recipe.endpoint, + substitution: { + from: nodeName, + componentId: 'http_request', // recipe=http_request+參數模板的具名封裝 + recipe: recipe.canonical_id, + reason: + `服務詞「${serviceHits.join('、')}」唯一命中 recipe「${recipe.canonical_id}」;` + + `workflow config 寫 component: ${recipe.canonical_id}(底層零件=http_request),只需填 payload`, + }, + }; + } + + // 規則 B:強欄位斷詞媒合 → 零件(至少一強命中+分數唯一最高) + const tokens = extractTokens(nodeName); + if (tokens.length === 0) return null; + + type Scored = { entry: CatalogFullRecord; score: number; strongHits: string[] }; + const byCanonical = new Map(); + for (const e of catalogEntries) { + const strongHay = [e.canonical_id, e.display_name ?? '', ...(e.aliases ?? [])].join(' ').toLowerCase(); + const weakHay = [e.description ?? '', ...(e.tags ?? [])].join(' ').toLowerCase(); + const strongHits = tokens.filter(t => strongHay.includes(t)); + const weakCount = tokens.filter(t => weakHay.includes(t)).length; + const score = strongHits.length * 10 + weakCount; + if (score === 0) continue; + const prev = byCanonical.get(e.canonical_id); + if (!prev || score > prev.score) byCanonical.set(e.canonical_id, { entry: e, score, strongHits }); + } + const ranked = [...byCanonical.values()].sort((a, b) => b.score - a.score); + const top = ranked[0]; + if (!top || top.strongHits.length === 0) return null; // 沒有強命中=證據不足 + if (ranked[1] && ranked[1].score >= top.score) return null; // 同分歧義=不硬猜 + + return { + status: 'resolved', + componentId: top.entry.canonical_id, + source: 'component', + 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, + substitution: { + from: nodeName, + componentId: top.entry.canonical_id, + reason: + `斷詞「${top.strongHits.join('、')}」命中零件「${top.entry.canonical_id}」` + + `(${top.entry.display_name ?? ''})強欄位且分數唯一最高;只需照 input_schema 填 payload`, + }, + }; +} + // ── 缺件分型(task 3.7)──────────────────────────────────────────────────────── // // 分型判準(刻意用簡單可解釋的規則,不接 LLM——查詢端點要快、要可預測): diff --git a/cypher-executor/src/actions/target-search.ts b/cypher-executor/src/actions/target-search.ts new file mode 100644 index 0000000..431c353 --- /dev/null +++ b/cypher-executor/src/actions/target-search.ts @@ -0,0 +1,98 @@ +/** + * target-search — POST /cypher/search 的「指定搜尋對象」名字搜尋(t159) + * + * leo 07-31:「search 節點名稱和 search 工作流名稱是同一個?一個死了另一個不能動? + * 難道我不能指定要搜尋工作流或節點或 recipe 嗎?」 + * + * ⇒ discover 入口加 `target`(component/recipe/workflow)+`query`: + * - target=component → 轉發 registry GET /components/search(MCP arcrun_search_components 同一條路) + * - target=recipe → 掃私庫 RECIPES KV(與 discover 混搜的第二庫**同一份讀法** listAllRecipes); + * 公庫(多作者市場)另有 /public-recipes=MCP arcrun_recipe_search,回應註明 + * - target=workflow → lib/workflow-search.ts(GET /workflows/search=MCP arcrun_search_workflows 同一條路) + * + * 「外部 API 只有一條一致的路」:三個 target 各自對應**既有**搜尋機制,本檔只做轉接, + * 不新造第二套搜尋。flag 安全:主動 pull,無輪詢。 + */ + +import { wasmWorkerUrl } from '../lib/component-loader'; +import { fetchTenantWorkflowSearch } from '../lib/workflow-search'; +import { listAllRecipes, type SearchNodesEnv } from './search-nodes'; + +export type TargetQueryEnv = SearchNodesEnv & { + KBDB_BASE_URL?: string; + KBDB_INTERNAL_TOKEN?: string; +}; + +export type TargetQueryResult = + | { ok: true; body: Record } + | { ok: false; status: 400 | 401 | 502; error: string }; + +export async function searchByTarget( + target: 'component' | 'recipe' | 'workflow', + query: string, + env: TargetQueryEnv, + apiKey?: string, +): Promise { + if (target === 'component') { + const sub = env.WORKER_SUBDOMAIN; + const registryBase = env.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined); + if (!registryBase) return { ok: false, status: 502, error: 'registry 位置未設定(WORKER_SUBDOMAIN/REGISTRY_BASE_URL 皆缺)' }; + try { + const res = await fetch( + `${registryBase}/components/search?q=${encodeURIComponent(query)}`, + { signal: AbortSignal.timeout(10000) }, + ); + if (!res.ok) return { ok: false, status: 502, error: `registry 搜尋失敗(HTTP ${res.status})` }; + const body = (await res.json()) as { data?: { results?: unknown[]; count?: number } }; + return { + ok: true, + body: { + target, + query, + results: body.data?.results ?? [], + count: body.data?.count ?? 0, + }, + }; + } catch (e) { + return { ok: false, status: 502, error: `registry 查不通:${e instanceof Error ? e.message : String(e)}` }; + } + } + + if (target === 'recipe') { + if (!env.RECIPES) return { ok: false, status: 502, error: 'RECIPES KV 未綁定' }; + const all = await listAllRecipes(env.RECIPES); + const q = query.toLowerCase(); + // 與 discover 混搜同一份庫(私庫=workflow 實際引用得到的);子字串比對、canonical 去重 + const seen = new Set(); + const results: Array<{ canonical_id: string; display_name?: string; description?: string; endpoint: 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 (!hay.includes(q)) continue; + seen.add(r.canonical_id); + results.push({ + canonical_id: r.canonical_id, + display_name: r.display_name, + description: r.description, + endpoint: r.endpoint, + }); + } + return { + ok: true, + body: { + target, + query, + results, + count: results.length, + note: '搜的是本部署私庫(workflow 可直接 component: 引用)。公庫(多作者市場)走 MCP arcrun_recipe_search/GET /public-recipes。', + }, + }; + } + + // target === 'workflow':租戶隔離,必帶 API key(同 GET /workflows/search 的既有契約) + if (!apiKey) return { ok: false, status: 401, error: 'target=workflow 需要 X-Arcrun-API-Key header(workflow 搜尋限本租戶)' }; + const res = await fetchTenantWorkflowSearch(env, apiKey, query); + if (!res.ok) return { ok: false, status: 502, error: `workflow 搜尋失敗(KBDB HTTP ${res.status})` }; + const body = (await res.json()) as Record; + return { ok: true, body: { target, query, ...body } }; +} diff --git a/cypher-executor/src/lib/workflow-search.ts b/cypher-executor/src/lib/workflow-search.ts new file mode 100644 index 0000000..0290d42 --- /dev/null +++ b/cypher-executor/src/lib/workflow-search.ts @@ -0,0 +1,49 @@ +/** + * workflow-search — 本租戶 workflow 名字搜尋的**唯一一條路** + * + * 既有機制(workflow-discovery 3.1):轉發 KBDB /entries/search + * (entry_type=workflow + owner_id=apiKey 租戶隔離;優先 semantic,KBDB 未開 + * Vectorize 自動降級 keyword + capability_hint)。 + * + * 為什麼抽成共用(leo 07-31:「search 節點名稱和 search 工作流名稱是同一個? + * 難道我不能指定要搜尋工作流或節點或 recipe 嗎?」+「外部 API 只有一條一致的路」鐵律): + * - GET /workflows/search(MCP arcrun_search_workflows 走的路) + * - POST /cypher/search { target: "workflow", query }(discover 入口指定搜尋對象) + * 兩個入口共用本函式 ⇒ 行為必然一致,改一處兩邊同步。 + * + * 已知缺口(如實透傳,不掩蓋):workflow_metadata 無 description slot—— + * 無 description 的 workflow 沒有 search entry、搜不到;補救走 + * POST /workflows/backfill-search-entries(有 description 的補 entry、沒有的誠實列出)。 + * + * flag 安全:主動 pull,無輪詢/排程。 + */ + +export type WorkflowSearchEnv = { + KBDB_BASE_URL?: string; + KBDB_INTERNAL_TOKEN?: string; +}; + +export type WorkflowSearchMode = 'semantic' | 'keyword'; + +/** + * 打 KBDB /entries/search(本租戶、entry_type=workflow)。 + * 回原始 Response——GET /workflows/search 直接 stream 透傳(既有行為,一字不改); + * target=workflow 的呼叫端自行 json() 解析。 + */ +export async function fetchTenantWorkflowSearch( + env: WorkflowSearchEnv, + apiKey: string, + q: string, + mode: WorkflowSearchMode = 'semantic', +): Promise { + const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, ''); + const headers: Record = { 'Content-Type': 'application/json' }; + if (env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${env.KBDB_INTERNAL_TOKEN}`; + const params = new URLSearchParams({ + q, + owner_id: apiKey, // 租戶隔離(只搜本租戶的 workflow) + entry_type: 'workflow', // base 通用 filter(Q4),只回 workflow entry + mode, + }); + return fetch(`${base}/entries/search?${params.toString()}`, { headers }); +} diff --git a/cypher-executor/src/routes/cypher.ts b/cypher-executor/src/routes/cypher.ts index 3730c8a..cd745ac 100644 --- a/cypher-executor/src/routes/cypher.ts +++ b/cypher-executor/src/routes/cypher.ts @@ -1,28 +1,63 @@ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { handleCypherSearch, handleCypherExecute } from '../actions/cypher-handlers'; +import { searchByTarget } from '../actions/target-search'; export const cypherRouter = new Hono<{ Bindings: Bindings }>(); +const VALID_TARGETS = new Set(['component', 'recipe', 'workflow']); + // POST /cypher/search — 三元組 → 解析節點 → 語意搜尋零件 → 回傳 Cypher JSON (開發友善格式) +// +// t159(leo 07-31):加 `target` 指定搜尋對象(component/recipe/workflow)+`query` 名字搜尋。 +// - triplets(不給 target)=混搜兩庫+意圖節點替換(步驟 4) +// - triplets + target=component|recipe=只查該庫 +// - query + target=名字搜尋,各自走**既有**機制(registry search/私庫 RECIPES/workflows/search) cypherRouter.post('/cypher/search', async (c) => { - const body = await c.req.json() as { triplets?: unknown; mode?: unknown }; + const body = await c.req.json() as { triplets?: unknown; mode?: unknown; target?: unknown; query?: unknown }; const rawTriplets = body?.triplets; + // ── target 驗證(component / recipe / workflow)───────────────────────────── + const target = typeof body?.target === 'string' ? body.target : undefined; + if (target !== undefined && !VALID_TARGETS.has(target)) { + return c.json({ error: `target 只接受 component/recipe/workflow,收到「${target}」` }, 400); + } + + // ── query 名字搜尋分支(需 target)────────────────────────────────────────── + const query = typeof body?.query === 'string' ? body.query.trim() : ''; + if (query) { + if (!target) { + return c.json({ error: '給 query 必須同時給 target(component/recipe/workflow),指明要搜哪個庫' }, 400); + } + const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined; + const r = await searchByTarget(target as 'component' | 'recipe' | 'workflow', query, c.env, apiKey); + if (!r.ok) return c.json({ error: r.error }, r.status); + return c.json(r.body); + } + if (!Array.isArray(rawTriplets) || rawTriplets.length === 0) { - return c.json({ error: 'triplets 必須為非空字串陣列' }, 400); + return c.json({ error: 'triplets 必須為非空字串陣列(或給 query + target 做名字搜尋)' }, 400); } // t158「部署≠發現」:mode=compile=純編圖(安裝器/acr push 的複製路徑,零存在性查詢); // 預設 discover=誠實查詢(AI 問「有沒有」的既有契約,not_found+指路照舊)。 const mode = body?.mode === 'compile' ? 'compile' : 'discover'; + // target 限庫只屬於 discover(compile=純複製,不查任何庫,target 無意義) + if (target && mode === 'compile') { + return c.json({ error: 'mode=compile(複製路徑)不查庫,不接受 target;要指定搜尋對象請用 discover(預設)' }, 400); + } + // workflow 是名字搜尋,不參與三元組編圖——請帶 query + if (target === 'workflow') { + return c.json({ error: 'target=workflow 是名字搜尋,請改帶 { target: "workflow", query: "..." }(不吃 triplets)' }, 400); + } + try { const now = new Date(); const timestamp = now.toISOString(); const versionId = `search-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`; - const result = await handleCypherSearch(rawTriplets, c.env, mode); + const result = await handleCypherSearch(rawTriplets, c.env, mode, target as 'component' | 'recipe' | undefined); const response = { version: versionId, diff --git a/cypher-executor/src/routes/webhooks-named.ts b/cypher-executor/src/routes/webhooks-named.ts index c8af7e4..b5b3c14 100644 --- a/cypher-executor/src/routes/webhooks-named.ts +++ b/cypher-executor/src/routes/webhooks-named.ts @@ -30,6 +30,7 @@ import type { GraphNode } from '../types'; import { extractCronExpr } from '../lib/cron-match'; import { updateCronIndexEntry, CRON_INDEX_KEY } from '../lib/cron-index'; import { recordTelemetry } from '../lib/telemetry'; +import { fetchTenantWorkflowSearch } from '../lib/workflow-search'; export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>(); @@ -177,16 +178,9 @@ webhooksNamedRouter.get('/workflows/search', async (c) => { // 預設優先語意;caller 傳 mode=keyword 才強制關鍵字。KBDB 端未開 Vectorize 會自動降級。 const mode = c.req.query('mode') === 'keyword' ? 'keyword' : 'semantic'; - const base = (c.env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, ''); - const headers: Record = { 'Content-Type': 'application/json' }; - if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`; - const params = new URLSearchParams({ - q, - owner_id: apiKey, // 租戶隔離(只搜本租戶的 workflow) - entry_type: 'workflow', // base 通用 filter(Q4),只回 workflow entry - mode, - }); - const res = await fetch(`${base}/entries/search?${params.toString()}`, { headers }); + // KBDB 轉發抽到 lib/workflow-search.ts(t159 target 參數):本路由與 + // POST /cypher/search { target:"workflow" } 共用同一條路,行為必然一致。 + const res = await fetchTenantWorkflowSearch(c.env, apiKey, q, mode); return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); }); diff --git a/system-dev/docs/3-specs/workflow-discovery/tasks.md b/system-dev/docs/3-specs/workflow-discovery/tasks.md index c123710..3dc6928 100644 --- a/system-dev/docs/3-specs/workflow-discovery/tasks.md +++ b/system-dev/docs/3-specs/workflow-discovery/tasks.md @@ -83,6 +83,35 @@ 有的照常編圖不必報告;缺的要兩庫(零件+recipe)都搜過後點名+給正確指示。 驗收兩層:機械(verify 01/03)綠 → haiku 真考(只讀回覆就能說出缺什麼、該做什麼)。 +- [x] 3.9 意圖節點→真實零件/recipe 替換(CP arcrun-usable **步驟 4**;頂層交棒 t159) + — 2026-07-31 search-nodes.ts `trySubstitution`:discover 混搜兩庫 exact 落空後, + 在 t158「一次抓好的兩庫清單」**記憶體內**媒合(零新增 round-trip)。兩條保守規則 + (不接 LLM,規則在 code 註解):A 服務詞→recipe(名字裡全部服務詞命中同一 recipe + 且唯一才換;「google_slides_create」不會被 google_sheets 誤吃);B 強欄位斷詞→零件 + (canonical/display/aliases 強命中×10+description/tags 弱命中,需至少一強命中且 + 分數唯一最高;「aes_encrypt」無強命中不換)。換到=status `resolved`+`substitution` + 欄(from/componentId/recipe/reason),cypher 圖節點直接帶真實 componentId、不列 + missing;換不到照舊 not_found+3.7 指路+候選。**只動 discover**——compile(部署/ + 推送複製路徑)零替換零查詢(t158 邊界不動)。 + 驗(本地 wrangler dev,registry 種 20 合約+/init/seed 10 recipe): + 「判斷有沒有新資料 >> ON_SUCCESS >> 傳到 telegram」→ if_control(resolved)+ + telegram_send(resolved,substitution.componentId=http_request)=feature 06 驗法過; + 機械考 27/27 全綠(01 組 5+03 組 4 迴歸+06 組 8+target 8+compile 迴歸 2); + 冷啟第一發 94ms、熱 8–13ms(t158 病史對照:舊 25.7s) +- [x] 3.10 `/cypher/search` 加 `target` 指定搜尋對象(leo 07-31:「難道我不能指定要搜尋 + 工作流或節點或 recipe 嗎?」)— component/recipe/workflow: + triplets+target=component|recipe=只查該庫;query+target=名字搜尋,各走**既有** + 機制不新造(component→registry /components/search=MCP arcrun_search_components + 同一條路;recipe→私庫 RECIPES KV 同 discover 第二庫讀法,回應註明公庫走 + arcrun_recipe_search;workflow→新抽 lib/workflow-search.ts,GET /workflows/search + 與 target=workflow 共用同一 KBDB 轉發=MCP arcrun_search_workflows 同一條路)。 + 防呆:mode=compile+target → 400;target=workflow 吃 query 不吃 triplets(400 指路); + 非法 target → 400。已知缺口如實透傳:workflow 無 description 搜不到(capability_hint + 照舊),補救仍走 /workflows/backfill-search-entries。 + MCP 三分型工具盤點:search_components/search_workflows/recipe_search 均註冊活著; + 前兩者與 target 走同一條路;recipe_search 搜公庫 vs target=recipe 搜私庫=語料不同 + 是設計(installed vs marketplace),回應互相指路,非行為漂移 + --- ## 跨任務鐵律提醒