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>
This commit is contained in:
uncle6me-web
2026-07-31 13:49:09 +08:00
parent 7e631a890a
commit d48f83ae6f
7 changed files with 374 additions and 20 deletions
@@ -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<string, unknown>; 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 };
+153 -5
View File
@@ -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 步驟 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;
@@ -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<string, Record<string, unknown>>,
env?: SearchNodesEnv,
mode: SearchMode = 'discover',
target?: SearchTarget,
): Promise<SearchResult> {
const nodeResults: Record<string, NodeInfo> = {};
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=只查零件 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 清單。
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=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);
@@ -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<CatalogFetch> {
}
}
/** 一次抓 recipe 全清單(本部署 recipe 數量小;exact 與相似度共用同一份)。 */
async function listAllRecipes(kv: KVNamespace): Promise<RecipeDefinition[]> {
/** 一次抓 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(
@@ -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_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 });
}
+38 -3
View File
@@ -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 (開發友善格式)
//
// 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; 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 只接受 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 {
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,
+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' } });
});
@@ -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),回應互相指路,非行為漂移
---
## 跨任務鐵律提醒