525faaf5d0
病因:/cypher/search 只查 component registry(SUBMISSIONS_KV,經 submitComponent/ index-only 才有記錄);而 component-loader.ts 能直接解析、從不查 registry 的一整類 零件(trigger_workflow/BUILTIN_COMPONENTS/LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS, 如 if_control/http_request/switch)從未被 submit 過。leo21c 實例實測: GET /components/catalog 回 404「零件 catalog 不存在」,search 因此對這些零件誠實地 回「兩庫都查過沒有」——但它們其實跑得動(leo 08-11 探測工作流已證)。 修法:從 component-loader.ts 匯出 RUNTIME_NATIVE_COMPONENT_IDS(既有三份執行期 解析白名單的聯集,非新清單),search-nodes.ts 在查 registry 之前先比對, 不受 registry 是否可達/是否已 backfill 影響。真正不存在的零件仍誠實回 not_found/unknown,not_found 的分型建議與相似候選機制不變。 刻意不做:不掃描 registry/components/* 目錄當清單來源——那含已標記待刪的死碼 (km_writer/kbdb_upsert_block),07-30 曾把這類死碼誤灌進 registry;也不投資 SUBMISSIONS_KV 的 backfill 腳本——decisions-summary.md D29 已定調 SUBMISSIONS_KV 併入「KV 退休戰」,不宜再加投資。 測試:cypher-executor/tests/search-nodes-runtime-native.test.ts 7 case 全綠, 複現 registry unreachable/registry 可達但目錄空(leo21c 實例的真實症狀)兩種情境。 全 suite 迴歸:357 pass(較修前 350 pass 多 7 個新測試),既有 14 個失敗與修前 數量、內容完全相同(pre-existing,與本次改動無關)。
745 lines
35 KiB
TypeScript
745 lines
35 KiB
TypeScript
import type { ParsedTriplets, NodeRole } from './triplet-parser';
|
||
import { resolveNodeRole, isVirtualIoName } from './triplet-parser';
|
||
import { wasmWorkerUrl, RUNTIME_NATIVE_COMPONENT_IDS } 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`:欄位契約以頂層機械考
|
||
* `system-dev/docs/3-specs/arcrun-usable/verify.sh` 為準(01 組 grep `not_found`)。
|
||
*/
|
||
/** `unchecked`=compile 模式的誠實標記:沒查、不知道有沒有(≠found 的假信號)。 */
|
||
/** `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;
|
||
componentId?: string;
|
||
type: NodeRole;
|
||
/**
|
||
* found 時標來源庫:零件 registry(component)、recipe 庫(recipe),
|
||
* 或 cypher-executor 自帶、無須查 registry 即保證解析得動的執行期原生零件(builtin,
|
||
* Arcrun#88——component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS)。
|
||
*/
|
||
source?: 'component' | 'recipe' | 'builtin';
|
||
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
|
||
input_schema?: unknown;
|
||
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
|
||
success_rate?: number;
|
||
stability?: string;
|
||
/** 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 組契約。
|
||
*/
|
||
suggestion?: string;
|
||
/** not_found 時的相近零件候選(自然語言節點名 → 既有零件的媒合)。 */
|
||
similar_components?: string[];
|
||
/** not_found 時的相近 recipe 候選。 */
|
||
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 = {
|
||
nodeResults: Record<string, NodeInfo>;
|
||
missingNodes: string[];
|
||
};
|
||
|
||
/**
|
||
* t158(leo 07-31 定調「部署≠發現」):
|
||
* 「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證,難怪這麼慢。
|
||
* 就算是我自己寫了錯的工作流,也可以跑跑看,如果錯誤就修改,
|
||
* 沒有說有錯誤還要一個個驗證這回事。」
|
||
* - `compile`=純編圖:**零外部查詢**(不打 registry、不掃 recipe、不算相似度、不擋 missing)。
|
||
* 部署/推送/執行路徑用——寫錯的 workflow 照樣部署,錯在執行時現形。
|
||
* - `discover`=誠實查詢(預設,`/cypher/search` 的既有契約):AI 問「有沒有」時用,
|
||
* not_found+分型指路+相似候選全保留。
|
||
*/
|
||
export type SearchMode = 'discover' | 'compile';
|
||
|
||
/** 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;
|
||
};
|
||
|
||
/**
|
||
* 對所有節點進行解析,確認每個節點對應的零件/recipe 是否**真的存在**。
|
||
*
|
||
* ⚠️ 2026-07-30 改為會查 registry(workflow-discovery task 3.x);
|
||
* 2026-07-31 再加 recipe 庫查詢+缺件分型指路(task 3.6/3.7)。
|
||
*
|
||
* 改之前的行為(病灶):無條件回 `status: 'found'`、`missingNodes` 永遠是空陣列——
|
||
* 實測「完全不存在的東西xyz」也回 found。
|
||
*
|
||
* 為什麼這是嚴重問題(leo 2026-07-30 定性「腹語術」):
|
||
* AI 寫意圖 → 查詢回「都 found」(假信號)→ 實際零件不存在
|
||
* → 部署/執行才發現 → 最快的修法是改寫成 `code` 節點自己寫 JS
|
||
* → 於是正式 workflow 只用 2 個零件、8 個 code 節點含 if×61
|
||
* ⇒ 「零件被測過 1000 次所以 AI 只要填 payload」的價值完全落空。
|
||
*
|
||
* 設計基調(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<string, Record<string, unknown>>,
|
||
env?: SearchNodesEnv,
|
||
mode: SearchMode = 'discover',
|
||
target?: SearchTarget,
|
||
): Promise<SearchResult> {
|
||
const nodeResults: Record<string, NodeInfo> = {};
|
||
const missingNodes: string[] = [];
|
||
|
||
// ── compile:純編圖,零外部查詢(t158,部署≠發現)─────────────────────────
|
||
if (mode === 'compile') {
|
||
for (const nodeName of parsed.nodeNames) {
|
||
const role = resolveNodeRole(nodeName, parsed);
|
||
if ((role === 'Input' || role === 'Output') && isVirtualIoName(nodeName)) {
|
||
nodeResults[nodeName] = { status: 'found', componentId: nodeName.toLowerCase(), type: role };
|
||
continue;
|
||
}
|
||
const configComponent = config?.[nodeName]?.component as string | undefined;
|
||
// unchecked=誠實「沒查」;存在性由 component-loader 在執行時決定
|
||
nodeResults[nodeName] = {
|
||
status: configComponent ? 'found' : 'unchecked',
|
||
componentId: configComponent ?? nodeName,
|
||
type: role,
|
||
};
|
||
}
|
||
return { nodeResults, missingNodes };
|
||
}
|
||
|
||
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 清單。
|
||
// 步驟 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<string, CatalogFullRecord>();
|
||
for (const e of catalog.entries) {
|
||
const prev = byId.get(e.canonical_id);
|
||
if (!prev || (e.score ?? 0) > (prev.score ?? 0)) byId.set(e.canonical_id, e);
|
||
for (const a of e.aliases ?? []) if (!byId.has(a)) byId.set(a, e);
|
||
}
|
||
|
||
for (const nodeName of parsed.nodeNames) {
|
||
const role = resolveNodeRole(nodeName, parsed);
|
||
|
||
// 只有**字面上的虛擬 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;
|
||
}
|
||
|
||
const configComponent = config?.[nodeName]?.component as string | undefined;
|
||
const componentId = configComponent ?? nodeName;
|
||
|
||
// config 明確給了 component(多半是安裝器代入的 worker URL 或既有 workflow)
|
||
// → 不判 not_found。這條路徑的存在性由 component-loader 在執行時決定(原行為)。
|
||
if (configComponent) {
|
||
nodeResults[nodeName] = { status: 'found', componentId, type: role };
|
||
continue;
|
||
}
|
||
|
||
// ── 執行期原生零件(Arcrun#88):查 registry 之前先比對 ──────────────────
|
||
// component-loader.ts 的 RUNTIME_NATIVE_COMPONENT_IDS=trigger_workflow/
|
||
// BUILTIN_COMPONENTS/LOGIC_BINDING_MAP/WASM_HTTP_RUNNER_IDS 的聯集——
|
||
// 這些零件 cypher-executor 自己就能 resolve,從不查 registry,執行期保證解析得動。
|
||
// 病史:registry 是空的/未部署新版 `/catalog` 端點時,這批零件(if_control/
|
||
// http_request/switch…)會被下面「兩庫都查過沒有」誤判成 not_found——
|
||
// 而 leo 08-11 實測探測工作流證明它們跑得動。命中即 found,不受 registry 健康狀態影響。
|
||
// target=recipe(使用者明確只要查 recipe 庫)不適用——這些從來不是 recipe。
|
||
if (wantComponents && RUNTIME_NATIVE_COMPONENT_IDS.has(componentId)) {
|
||
nodeResults[nodeName] = {
|
||
status: 'found',
|
||
componentId,
|
||
type: role,
|
||
source: 'builtin',
|
||
branch_hint: branchHintFor(componentId),
|
||
};
|
||
continue;
|
||
}
|
||
|
||
// registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。
|
||
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
|
||
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
|
||
if (catalog.status === 'unreachable') {
|
||
nodeResults[nodeName] = { status: 'unknown', componentId, type: role };
|
||
continue;
|
||
}
|
||
if (catalog.status === 'no_endpoint') {
|
||
const legacy = await legacyPerNodeLookup(registryBase!, componentId, nodeName, role, env, recipes);
|
||
nodeResults[nodeName] = legacy.info;
|
||
if (legacy.missing) missingNodes.push(nodeName);
|
||
continue;
|
||
}
|
||
|
||
// ── 第一庫:零件 catalog(記憶體)────────────────────────────────────────
|
||
const hit = byId.get(componentId);
|
||
if (hit) {
|
||
nodeResults[nodeName] = {
|
||
status: 'found',
|
||
componentId,
|
||
type: role,
|
||
source: 'component',
|
||
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;
|
||
}
|
||
|
||
// ── 第二庫:recipe 清單(記憶體;canonical_id 精確比對)──────────────────
|
||
const recipe = recipes.find(r => r.canonical_id === componentId);
|
||
if (recipe) {
|
||
nodeResults[nodeName] = {
|
||
status: 'found',
|
||
componentId: recipe.canonical_id,
|
||
type: role,
|
||
source: 'recipe',
|
||
description: recipe.description,
|
||
endpoint: recipe.endpoint,
|
||
payload_hint: buildPayloadHint(recipe),
|
||
};
|
||
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);
|
||
|
||
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 };
|
||
}
|
||
|
||
// ── t158 批次化 helpers ────────────────────────────────────────────────────────
|
||
|
||
type CatalogFullRecord = {
|
||
canonical_id: string;
|
||
display_name?: string;
|
||
description?: string;
|
||
aliases?: string[];
|
||
tags?: string[];
|
||
score?: number;
|
||
input_schema?: unknown;
|
||
success_rate?: number;
|
||
stability?: string;
|
||
};
|
||
|
||
type CatalogFetch = { status: 'ok' | 'no_endpoint' | 'unreachable'; entries: CatalogFullRecord[] };
|
||
|
||
/** 一次抓 registry 全目錄。404=舊版 registry 沒這端點 → 呼叫端退回逐顆查。 */
|
||
async function fetchCatalog(registryBase: string): Promise<CatalogFetch> {
|
||
try {
|
||
const res = await fetch(`${registryBase}/components/catalog`, { signal: AbortSignal.timeout(10000) });
|
||
if (res.status === 404) return { status: 'no_endpoint', entries: [] };
|
||
if (!res.ok) return { status: 'unreachable', entries: [] };
|
||
const body = (await res.json()) as { data?: { components?: CatalogFullRecord[] } };
|
||
return { status: 'ok', entries: body.data?.components ?? [] };
|
||
} catch {
|
||
return { status: 'unreachable', entries: [] };
|
||
}
|
||
}
|
||
|
||
/** 一次抓 recipe 全清單(本部署 recipe 數量小;exact 與相似度共用同一份)。
|
||
* export 給 target=recipe 的名字搜尋(actions/target-search.ts)共用同一份讀法。 */
|
||
export async function listAllRecipes(kv: KVNamespace): Promise<RecipeDefinition[]> {
|
||
try {
|
||
const list = await kv.list({ prefix: 'recipe:' });
|
||
return (await Promise.all(
|
||
list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
|
||
)).filter(Boolean) as RecipeDefinition[];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** 相似零件(記憶體版):全名 substring 優先,否則斷詞計數 top3——判準與舊 HTTP 版一致。 */
|
||
function similarFromCatalog(entries: CatalogFullRecord[], nodeName: string): string[] {
|
||
const searchableOf = (e: CatalogFullRecord) =>
|
||
[e.canonical_id, e.display_name ?? '', e.description ?? '', ...(e.aliases ?? []), ...(e.tags ?? [])]
|
||
.join(' ').toLowerCase();
|
||
const full = nodeName.toLowerCase();
|
||
const direct = entries.filter(e => searchableOf(e).includes(full)).map(e => e.canonical_id);
|
||
if (direct.length > 0) return [...new Set(direct)].slice(0, 3);
|
||
|
||
const tokens = extractTokens(nodeName);
|
||
if (tokens.length === 0) return [];
|
||
const count = new Map<string, number>();
|
||
for (const e of entries) {
|
||
const hay = searchableOf(e);
|
||
const hits = tokens.filter(t => hay.includes(t)).length;
|
||
if (hits > 0) count.set(e.canonical_id, Math.max(count.get(e.canonical_id) ?? 0, hits));
|
||
}
|
||
return [...count.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([id]) => id);
|
||
}
|
||
|
||
/** 相似 recipe(記憶體版;判準沿用 searchSimilarRecipes)。 */
|
||
function similarFromRecipes(recipes: RecipeDefinition[], nodeName: string): string[] {
|
||
const tokens = [nodeName.toLowerCase(), ...extractTokens(nodeName)];
|
||
const seen = new Set<string>();
|
||
const matched: string[] = [];
|
||
for (const r of recipes) {
|
||
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);
|
||
}
|
||
|
||
/** 舊 registry(無 /catalog 端點)的相容路徑:維持逐顆查語義。 */
|
||
async function legacyPerNodeLookup(
|
||
registryBase: string,
|
||
componentId: string,
|
||
nodeName: string,
|
||
role: NodeRole,
|
||
env: SearchNodesEnv | undefined,
|
||
recipes: RecipeDefinition[],
|
||
): Promise<{ info: NodeInfo; missing: boolean }> {
|
||
const q = await fetchComponent(registryBase, componentId);
|
||
if (!q.ok) return { info: { status: 'unknown', componentId, type: role }, missing: false };
|
||
if (q.entry) {
|
||
return {
|
||
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,
|
||
};
|
||
}
|
||
const recipe = recipes.find(r => r.canonical_id === componentId)
|
||
?? (env?.RECIPES ? await resolveRecipe(componentId, env.RECIPES) : null);
|
||
if (recipe) {
|
||
return {
|
||
info: {
|
||
status: 'found', componentId: recipe.canonical_id, type: role, source: 'recipe',
|
||
description: recipe.description, endpoint: recipe.endpoint,
|
||
payload_hint: buildPayloadHint(recipe),
|
||
},
|
||
missing: false,
|
||
};
|
||
}
|
||
const similarComponents = await searchSimilarComponents(registryBase, nodeName);
|
||
const similarRecipes = similarFromRecipes(recipes, nodeName);
|
||
return {
|
||
info: {
|
||
status: 'not_found', componentId, type: role, suggestion: buildSuggestion(componentId),
|
||
...(similarComponents.length > 0 ? { similar_components: similarComponents } : {}),
|
||
...(similarRecipes.length > 0 ? { similar_recipes: similarRecipes } : {}),
|
||
},
|
||
missing: true,
|
||
};
|
||
}
|
||
|
||
// ── 步驟 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' | 'branch_hint'
|
||
>;
|
||
|
||
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<string, RecipeDefinition>();
|
||
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<string, Scored>();
|
||
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,
|
||
// 替換成分岔零件時(例「判斷有沒有新資料」→ if_control)一併附分支用法,
|
||
// 否則 AI 換到零件卻不知道怎麼接兩條路,仍會退回寫 code。
|
||
branch_hint: branchHintFor(top.entry.canonical_id),
|
||
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——查詢端點要快、要可預測):
|
||
// 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 沙箱)。`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 = {
|
||
input_schema?: unknown;
|
||
success_rate?: number;
|
||
stability?: string;
|
||
};
|
||
|
||
/**
|
||
* 查單一零件是否存在於 registry。
|
||
*
|
||
* ⚠️ 為什麼逐個查而非抓整份目錄:registry **沒有列表端點**
|
||
* (實測 `GET /components` → 404,只有 `GET /components/<id>`)。
|
||
* 這是 CP2-B 記載的缺口(「修 /components 404」)——補了列表端點後可改為抓一次。
|
||
* 現階段逐個查:節點數通常 <10,且有 5s timeout,可接受。
|
||
*
|
||
* 回傳 `ok:false` 代表「查不到 registry」,由呼叫端區分:
|
||
* 整體查不通 → `unknown`;查得通但這顆沒有 → 繼續查 recipe 庫。
|
||
*/
|
||
async function fetchComponent(
|
||
registryBase: string,
|
||
id: string,
|
||
): Promise<{ ok: boolean; entry?: CatalogEntry }> {
|
||
try {
|
||
const res = await fetch(`${registryBase}/components/${encodeURIComponent(id)}`, {
|
||
signal: AbortSignal.timeout(5000),
|
||
});
|
||
if (res.status === 404) return { ok: true }; // registry 活著,但沒這顆
|
||
if (!res.ok) return { ok: false };
|
||
const body = (await res.json()) as { success?: boolean; data?: Record<string, unknown> };
|
||
if (body.success === false) return { ok: true }; // 同上:回「零件不存在」
|
||
const d = body.data ?? (body as unknown as Record<string, unknown>);
|
||
return {
|
||
ok: true,
|
||
entry: {
|
||
input_schema: d.input_schema,
|
||
success_rate: typeof d.success_rate === 'number' ? d.success_rate : undefined,
|
||
stability: typeof d.stability === 'string' ? d.stability : undefined,
|
||
},
|
||
};
|
||
} catch {
|
||
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<string[]> {
|
||
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<string[]> {
|
||
// 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<string, number>();
|
||
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<string[]> {
|
||
try {
|
||
const list = await kv.list({ prefix: 'recipe:' });
|
||
const all = (await Promise.all(
|
||
list.keys.map(k => kv.get(k.name, 'json') as Promise<RecipeDefinition | null>),
|
||
)).filter(Boolean) as RecipeDefinition[];
|
||
|
||
const tokens = [nodeName.toLowerCase(), ...extractTokens(nodeName)];
|
||
const seen = new Set<string>();
|
||
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 [];
|
||
}
|
||
}
|