3.6/3.7 查詢誠實化:兩庫都查、缺件回 not_found+分型指路 suggestion

📋 SDD:workflow-discovery task 3.6/3.7(leo 07-31 步驟 3 考題)
契約以頂層 arcrun-usable/verify.sh 01+03 組為準。

- 3.6 recipe 納入 /cypher/search:零件 registry 落空後查 RECIPES KV
  (resolveRecipe,執行鏈同一套解析);recipe found 附 source/description/endpoint
- 3.7 缺件分型指路:status 契約定 not_found(verify grep),suggestion 欄——
  名字含服務詞(google/telegram/…)=外部 API 樣貌 → 指 skill write_recipe;
  含計算詞(encrypt/hash/…)=計算原語樣貌 → 指 skill add_new_wasm_component 投稿 PR;
  判不出型誠實說判不出、兩條路都給。規則簡單可解釋(不接 LLM),判準在 code 註解
- 相近候選:not_found 附 similar_components/similar_recipes——全名搜不中就斷詞
  (ASCII 詞+中日韓 2-gram)打 registry /components/search,
  「判斷有沒有新資料」媒合到 if_control(leo:AI 不用知道零件存在)
- 修頭節點假 found:resolveNodeRole 把無入邊頭節點判 Input,searchNodes 對 Input
  無條件 found ⇒ 「aes_encrypt >> … >> code」的頭被掩蓋。改為只有字面
  input/trigger/…/output 名才短路(isVirtualIoName),真名字照查兩庫
- registry 查詢層補 input_schema/output_schema 透傳:KV 一直有存
  (indexOnlyComponent),toComponentRecord 丟掉 ⇒ /cypher/search 給不出
  「怎麼填 payload」。順手補 REGISTRY_BASE_URL 可選覆蓋(比照 KBDB_GRAPH_URL 慣例,
  本地 dev/self-hosted registry 掛別處時用)

驗:cypher-executor+registry tsc 全綠;vitest 9 failed/179 passed=與 5cadc60
基線完全相同(既有債非本次造成);本地 wrangler dev(cypher 指本地 registry,
KV 用 index-only 種入 20 份 repo 合約)跑頂層 verify.sh 01+03 組 9/9 全綠,
含 telegram_send 種入後 recipe found 路徑實測。部署到實例後需對真環境重驗。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-31 11:56:15 +08:00
parent 747dc3f843
commit 1794072179
5 changed files with 259 additions and 23 deletions
+220 -20
View File
@@ -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 時標來源庫:零件 registrycomponent)或 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):兩庫(零件 registryrecipe 庫)都查過才點名,
* 並告訴 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 改為會查 registryworkflow-discovery task 3.x
* ⚠️ 2026-07-30 改為會查 registryworkflow-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 → 自己寫 recipeskill `write_recipe`);
* 缺計算原語 → 投稿零件 PRskill `add_new_wasm_component`)。
*
* 誠實限制:查不到 registry(未部署/網路失敗)時回 `'unknown'` 而不是 `'not_found'`——
* 不能因為查詢失敗就宣告零件不存在(那會讓 AI 誤判而重寫 code,正是要避免的事)。
*/
export async function searchNodes(
parsed: ParsedTriplets,
config?: Record<string, Record<string, unknown>>,
env?: { WORKER_SUBDOMAIN?: string },
env?: SearchNodesEnv,
): Promise<SearchResult> {
const nodeResults: Record<string, NodeInfo> = {};
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) 名字含**外部服務詞**googletelegramslack…)→「外部 API 樣貌」
// → recipe 路:recipe 是 http_request+參數模板的具名封裝,用戶自己就能寫,不用改平台。
// 2) 否則名字含**計算原語詞**encrypthashencode…)→「計算原語樣貌」
// → 零件路:純計算得進 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 → 自己寫 recipeskill「write_recipe」);` +
`缺計算能力 → 投稿零件 PRskill「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-gramregistry search 是子字串比對,整句中文必落空,
// 2-gram 才撈得到「判斷」→ if_controldisplay_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 [];
}
}
@@ -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
*
* 規則:
+4
View File
@@ -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.<subdomain>.workers.devwasmWorkerUrl 慣例)。
// 本地 wrangler devself-hosted 把 registry 掛別處時覆蓋(/cypher/search 存在性查詢用)。
REGISTRY_BASE_URL?: string;
// kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算
// https://kbdb-graph-plugin.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。