Compare commits

...

2 Commits

Author SHA1 Message Date
uncle6me-web d48f83ae6f t159 步驟4 意圖節點→真實零件/recipe 替換+/cypher/search 加 target 指定搜尋對象
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 <noreply@anthropic.com>
2026-07-31 13:49:09 +08:00
uncle6me-web 7e631a890a t158 迴歸修復「部署≠發現」:複製路徑回毫秒級純編圖,誠實化只留在 discover
leo 07-31 定調:「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證,難怪這麼慢。
就算是我自己寫了錯的工作流,也可以跑跑看,如果錯誤就修改,
沒有說有錯誤還要一個個驗證這回事。」

定性=迴歸非新設計:部署路徑純複製是既有設計(arcrun-rag installer/src/index.js:13
「workflows.json 是既有 workflows/*.local.yaml 的搬運(打包期抽 flow/config)」+
arcrun-rag wiki「workflow 打包期預編成 workflows.json(worker 免帶 parser)」)。
5cadc60 起誠實化漏進 /cypher/search ⇒ 複製路徑也逐節點跑兩庫查詢+相似搜尋
(每 missing 節點 1+9 次 HTTP+recipe KV 掃)⇒ 冷實例 8 節點實測 25.7s、
安裝器 15s timeout 必炸(leo stage 實走 rag_takedown_direct aborted)。

改動:
- /cypher/search 加 mode:compile=純編圖零查詢(安裝器/acr push 複製路徑);
  discover=誠實查詢預設(AI 問「有沒有」的既有契約,not_found+分型指路全保留)
- /cypher/execute 一律 compile(存在性由 component-loader 執行時決定=原權威)
- compile 的節點 status 標 unchecked(誠實「沒查」,不回假 found)
- discover 批次化:registry 新增 GET /components/catalog(一次回全目錄含
  input_schema,補 CP2-B「沒有列表端點」缺口)+recipe 清單一次抓,
  存在判定與相似度全記憶體比對;舊 registry 無 catalog 端點 → 退回逐顆(相容);
  registry 整個查不通 → unknown 照舊(不誤判 not_found)
- cli push 帶 mode:compile+拔 missing 擋(push 不看 missing;要問有沒有走 validate)

驗(本地 wrangler dev 誠實環境,registry 種 20 合約):
- compile 編圖:graph_neighbors 39ms/rag_chat(11節點) 3ms/rag_ingest_card 2ms/
  rag_takedown_direct(8節點) 3ms——回迴歸前毫秒級
- 安裝器 pushWorkflowTo(mode:compile)4/4 ok(44/7/7/5ms)
- 故意引用不存在零件的 workflow:部署 ok=true,trigger 執行時誠實報
  「找不到零件…」+可用零件清單(部署≠發現實證)
- discover 契約:邏輯名 missing=2+not_found+suggestion(21ms);
  頂層 verify.sh 01 組 5/5+03 組 4/4 全綠
- cypher+registry tsc 全綠;vitest 9 failed/179 passed=5cadc60 基線完全相同

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:31:33 +08:00
10 changed files with 601 additions and 44 deletions
+5 -5
View File
@@ -55,10 +55,12 @@ export async function cmdPush(filePath: string): Promise<void> {
const searchSpinner = ora('取得執行圖').start();
let graph: unknown;
try {
// t158「部署≠發現」(leo:「這裡只是複製工作流的 data 過去,沒有要在這裡驗證」):
// push=複製路徑,帶 mode:compile 純編圖——寫錯的 workflow 照樣部署,錯在執行時現形。
const res = await fetch(`${executorUrl}/cypher/search`, {
method: 'POST',
headers,
body: JSON.stringify({ triplets: workflow.flow }),
body: JSON.stringify({ triplets: workflow.flow, mode: 'compile' }),
});
if (!res.ok) {
@@ -68,10 +70,8 @@ export async function cmdPush(filePath: string): Promise<void> {
}
const data = await res.json() as { cypher: { nodes: unknown[]; edges: unknown[] }; missing: string[] };
if (data.missing?.length > 0) {
searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`));
process.exit(1);
}
// t158push 不看 missingcompile 模式亦恆空)——存在性由執行時 component-loader 決定;
// 要「先問有沒有」用 acr validateMCP 查詢(discover 路徑)。
// 附上 id / name,並將 workflow.config 套入節點(componentId + data
const rawGraph = data.cypher as { nodes: Array<{ id: string; componentId?: string; data?: Record<string, unknown> }>; edges: unknown[] };
+12 -3
View File
@@ -5,12 +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 } 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<string, unknown>; cypher: unknown; missing: string[] }> {
const parsed = parseTriplets(triplets);
if (!parsed) {
@@ -19,7 +21,12 @@ export async function handleCypherSearch(
// 2026-07-30:查 registry 判真實存在(workflow-discovery)。
// `missing` 以前寫死 [],等於告訴 AI「什麼都有」——那是「腹語術」的入口。
const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env);
//
// t15807-31 迴歸修復,leo:「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證」):
// 誠實化只屬於 **discover**AI 問「有沒有」);**compile**(部署/推送的複製路徑)
// 純編圖零查詢——那本來就是既有設計(workflows.json=打包期預編的搬運),
// 5cadc60 起誠實化漏進複製路徑=迴歸(冷實例 8 節點 25.7s、安裝器 timeout 炸)。
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 };
@@ -52,7 +59,9 @@ export async function handleCypherExecute(
throw new Error('無法解析任何節點');
}
const { nodeResults } = await searchNodes(parsed, config, env);
// t158:執行路徑=compile(零 discovery round-trip)——存在性由 component-loader
// 在載入該節點時決定(原本的權威),查詢層不重複驗。
const { nodeResults } = await searchNodes(parsed, config, env, 'compile');
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName, config);
const parseResult = graphSchema.safeParse(graph);
+336 -21
View File
@@ -8,7 +8,35 @@ import type { RecipeDefinition } from '../routes/recipes';
* `not_found` 而非 `missing`:欄位契約以頂層機械考
* `system-dev/docs/3-specs/arcrun-usable/verify.sh` 為準(01 組 grep `not_found`)。
*/
export type NodeStatus = 'found' | 'not_found' | 'unknown';
/** `unchecked`compile 模式的誠實標記:沒查、不知道有沒有(≠found 的假信號)。 */
/** `resolved`=意圖節點被媒合替換成真實零件/recipe(步驟 4;≠字面 exact 的 found)。 */
export type NodeStatus = 'found' | 'not_found' | 'unknown' | 'unchecked' | 'resolved';
/**
* 意圖節點 → 真實零件/recipe 的替換結果(CP 步驟 4workflow-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;
@@ -33,6 +61,8 @@ export type NodeInfo = {
similar_components?: string[];
/** not_found 時的相近 recipe 候選。 */
similar_recipes?: string[];
/** resolved 時的替換明細(步驟 4:意圖節點 → 真實零件/recipe)。 */
substitution?: NodeSubstitution;
};
export type SearchResult = {
@@ -40,6 +70,18 @@ export type SearchResult = {
missingNodes: string[];
};
/**
* t158leo 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;
@@ -79,13 +121,55 @@ 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=只查零件 registryrecipe=只查 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 次 catalog1 次 recipe 清單。
// 步驟 4 的意圖替換也在**同一份清單**上做——不加任何新 round-trip。
const catalog = !wantComponents
? { status: 'ok' as const, entries: [] } // target=reciperegistry 不參與,不因此回 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);
@@ -107,34 +191,37 @@ export async function searchNodes(
continue;
}
if (!registryBase) {
// 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;
}
// ── 第一庫:零件 registry ────────────────────────────────────────────────
const q = await fetchComponent(registryBase, componentId);
if (!q.ok) {
// registry 查不通(未部署/網路失敗)⇒ 誠實回 unknown。
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
nodeResults[nodeName] = { status: 'unknown', componentId, type: role };
continue;
}
if (q.entry) {
// ── 第一庫:零件 catalog(記憶體)────────────────────────────────────────
const hit = byId.get(componentId);
if (hit) {
nodeResults[nodeName] = {
status: 'found',
componentId,
type: role,
source: 'component',
input_schema: q.entry.input_schema,
success_rate: q.entry.success_rate,
stability: q.entry.stability,
input_schema: hit.input_schema,
success_rate: typeof hit.success_rate === 'number' ? hit.success_rate : undefined,
stability: typeof hit.stability === 'string' ? hit.stability : undefined,
};
continue;
}
// ── 第二庫:recipe 庫(task 3.6——「說不出某 recipe 沒有」的病灶就在漏了這步)──
const recipe = env?.RECIPES ? await resolveRecipe(componentId, env.RECIPES) : null;
// ── 第二庫:recipe 清單(記憶體;canonical_id 精確比對)──────────────────
const recipe = recipes.find(r => r.canonical_id === componentId);
if (recipe) {
nodeResults[nodeName] = {
status: 'found',
@@ -147,11 +234,18 @@ export async function searchNodes(
continue;
}
// ── 兩庫都沒有 ⇒ not_found + 分型指路(task 3.7)+ 相近候選 ────────────
const [similarComponents, similarRecipes] = await Promise.all([
searchSimilarComponents(registryBase, nodeName),
env?.RECIPES ? searchSimilarRecipes(env.RECIPES, nodeName) : Promise.resolve([]),
]);
// ── 步驟 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',
@@ -167,6 +261,227 @@ export async function searchNodes(
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,
},
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,
},
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_iddisplay_namealiases
// 命中為主:分數=強命中×10+弱命中(descriptiontags)×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<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', // recipehttp_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,
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——查詢端點要快、要可預測):
@@ -0,0 +1,98 @@
/**
* target-search — POST /cypher/search 的「指定搜尋對象」名字搜尋(t159)
*
* leo 07-31:「search 節點名稱和 search 工作流名稱是同一個?一個死了另一個不能動?
* 難道我不能指定要搜尋工作流或節點或 recipe 嗎?」
*
* ⇒ discover 入口加 `target`componentrecipeworkflow)+`query`
* - target=component → 轉發 registry GET /components/searchMCP arcrun_search_components 同一條路)
* - target=recipe → 掃私庫 RECIPES KV(與 discover 混搜的第二庫**同一份讀法** listAllRecipes);
* 公庫(多作者市場)另有 /public-recipesMCP arcrun_recipe_search,回應註明
* - target=workflow → lib/workflow-search.tsGET /workflows/searchMCP 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<string, unknown> }
| { ok: false; status: 400 | 401 | 502; error: string };
export async function searchByTarget(
target: 'component' | 'recipe' | 'workflow',
query: string,
env: TargetQueryEnv,
apiKey?: string,
): Promise<TargetQueryResult> {
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_SUBDOMAINREGISTRY_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<string>();
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: <canonical_id> 引用)。公庫(多作者市場)走 MCP arcrun_recipe_searchGET /public-recipes。',
},
};
}
// target === 'workflow':租戶隔離,必帶 API key(同 GET /workflows/search 的既有契約)
if (!apiKey) return { ok: false, status: 401, error: 'target=workflow 需要 X-Arcrun-API-Key headerworkflow 搜尋限本租戶)' };
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<string, unknown>;
return { ok: true, body: { target, query, ...body } };
}
@@ -0,0 +1,49 @@
/**
* workflow-search — 本租戶 workflow 名字搜尋的**唯一一條路**
*
* 既有機制(workflow-discovery 3.1):轉發 KBDB /entries/search
* entry_type=workflow + owner_id=apiKey 租戶隔離;優先 semanticKBDB 未開
* Vectorize 自動降級 keyword + capability_hint)。
*
* 為什麼抽成共用(leo 07-31:「search 節點名稱和 search 工作流名稱是同一個?
* 難道我不能指定要搜尋工作流或節點或 recipe 嗎?」+「外部 API 只有一條一致的路」鐵律):
* - GET /workflows/searchMCP 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<Response> {
const base = (env.KBDB_BASE_URL ?? 'https://arcrun-kbdb.uncle6-me.workers.dev').replace(/\/$/, '');
const headers: Record<string, string> = { '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 通用 filterQ4),只回 workflow entry
mode,
});
return fetch(`${base}/entries/search?${params.toString()}`, { headers });
}
+42 -3
View File
@@ -1,16 +1,55 @@
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 (開發友善格式)
//
// t159leo 07-31):加 `target` 指定搜尋對象(componentrecipeworkflow)+`query` 名字搜尋。
// - triplets(不給 target)=混搜兩庫+意圖節點替換(步驟 4)
// - triplets + target=component|recipe=只查該庫
// - query + target=名字搜尋,各自走**既有**機制(registry search/私庫 RECIPESworkflows/search
cypherRouter.post('/cypher/search', async (c) => {
const body = await c.req.json() as { triplets?: 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 只接受 componentrecipeworkflow,收到「${target}` }, 400);
}
// ── query 名字搜尋分支(需 target)──────────────────────────────────────────
const query = typeof body?.query === 'string' ? body.query.trim() : '';
if (query) {
if (!target) {
return c.json({ error: '給 query 必須同時給 targetcomponentrecipeworkflow),指明要搜哪個庫' }, 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 限庫只屬於 discovercompile=純複製,不查任何庫,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 {
@@ -18,7 +57,7 @@ cypherRouter.post('/cypher/search', async (c) => {
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);
const result = await handleCypherSearch(rawTriplets, c.env, mode, target as 'component' | 'recipe' | undefined);
const response = {
version: versionId,
+4 -10
View File
@@ -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<string, string> = { '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 通用 filterQ4),只回 workflow entry
mode,
});
const res = await fetch(`${base}/entries/search?${params.toString()}`, { headers });
// KBDB 轉發抽到 lib/workflow-search.tst159 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' } });
});
+1 -1
View File
@@ -145,7 +145,7 @@ function computeScore(v: Record<string, unknown>): number {
return successRate * speedScore * Math.log(callCount + 2);
}
function toComponentRecord(v: Record<string, unknown>): ComponentRecord {
export function toComponentRecord(v: Record<string, unknown>): ComponentRecord {
return {
component_hash_id: String(v.component_hash_id ?? ''),
canonical_id: String(v.canonical_id ?? ''),
+25 -1
View File
@@ -5,10 +5,34 @@
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { getComponent, getComponentVersions, searchComponents } from '../actions/queryComponents';
import { getComponent, getComponentVersions, searchComponents, toComponentRecord } from '../actions/queryComponents';
import type { ComponentRecord } from '../actions/queryComponents';
const app = new Hono<{ Bindings: Bindings }>();
// 全清單(t158 批次化):/cypher/search discover 一次抓走整份目錄,
// 節點存在判定+相似度全在 cypher 記憶體內比對——取代「每個 missing 節點
// 各打 1+8 次查詢」的疊爆模式(冷實例 8 節點實測 25.7s 的病根)。
// 也補上 CP2-B 記載的「registry 沒有列表端點」缺口。
// 必須在 /:id 之前,避免 "catalog" 被當作 id。
app.get('/catalog', async c => {
const list = await c.env.SUBMISSIONS_KV.list({ prefix: 'comp:' });
const seen = new Set<string>();
const components: ComponentRecord[] = [];
for (const key of list.keys) {
const raw = await c.env.SUBMISSIONS_KV.get(key.name);
if (!raw) continue;
let v: Record<string, unknown>;
try { v = JSON.parse(raw) as Record<string, unknown>; } catch { continue; }
if (v.status === 'tombstone' || v.visibility !== 'public') continue;
const dedup = `${String(v.component_hash_id ?? '')}:${String(v.version ?? '')}`;
if (seen.has(dedup)) continue;
seen.add(dedup);
components.push(toComponentRecord(v));
}
return c.json({ success: true, data: { components, count: components.length } });
});
// 語意搜尋(必須在 /:id 之前,避免 "search" 被當作 id
app.get('/search', async c => {
const q = c.req.query('q');
@@ -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 強命中×10description/tags 弱命中,需至少一強命中且
分數唯一最高;「aes_encrypt」無強命中不換)。換到=status `resolved``substitution`
欄(from/componentId/recipe/reason),cypher 圖節點直接帶真實 componentId、不列
missing;換不到照舊 not_found3.7 指路+候選。**只動 discover**——compile(部署/
推送複製路徑)零替換零查詢(t158 邊界不動)。
驗(本地 wrangler devregistry 種 20 合約+/init/seed 10 recipe):
「判斷有沒有新資料 >> ON_SUCCESS >> 傳到 telegram」→ if_controlresolved)+
telegram_sendresolvedsubstitution.componentId=http_request)=feature 06 驗法過;
機械考 27/27 全綠(01 組 503 組 4 迴歸+06 組 8target 8compile 迴歸 2);
冷啟第一發 94ms、熱 8–13mst158 病史對照:舊 25.7s
- [x] 3.10 `/cypher/search``target` 指定搜尋對象(leo 07-31:「難道我不能指定要搜尋
工作流或節點或 recipe 嗎?」)— componentrecipeworkflow
tripletstarget=component|recipe=只查該庫;querytarget=名字搜尋,各走**既有**
機制不新造(component→registry /components/searchMCP arcrun_search_components
同一條路;recipe→私庫 RECIPES KV 同 discover 第二庫讀法,回應註明公庫走
arcrun_recipe_searchworkflow→新抽 lib/workflow-search.tsGET /workflows/search
與 target=workflow 共用同一 KBDB 轉發=MCP arcrun_search_workflows 同一條路)。
防呆:mode=compiletarget → 400target=workflow 吃 query 不吃 triplets400 指路);
非法 target → 400。已知缺口如實透傳:workflow 無 description 搜不到(capability_hint
照舊),補救仍走 /workflows/backfill-search-entries。
MCP 三分型工具盤點:search_componentssearch_workflowsrecipe_search 均註冊活著;
前兩者與 target 走同一條路;recipe_search 搜公庫 vs target=recipe 搜私庫=語料不同
是設計(installed vs marketplace),回應互相指路,非行為漂移
---
## 跨任務鐵律提醒