d48f83ae6f
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>
134 lines
5.9 KiB
TypeScript
134 lines
5.9 KiB
TypeScript
import { Hono } from 'hono';
|
||
import type { Bindings } from '../types';
|
||
import { handleCypherSearch, handleCypherExecute } from '../actions/cypher-handlers';
|
||
import { searchByTarget } from '../actions/target-search';
|
||
|
||
export const cypherRouter = new Hono<{ Bindings: Bindings }>();
|
||
|
||
const VALID_TARGETS = new Set(['component', 'recipe', 'workflow']);
|
||
|
||
// POST /cypher/search — 三元組 → 解析節點 → 語意搜尋零件 → 回傳 Cypher JSON (開發友善格式)
|
||
//
|
||
// t159(leo 07-31):加 `target` 指定搜尋對象(component/recipe/workflow)+`query` 名字搜尋。
|
||
// - triplets(不給 target)=混搜兩庫+意圖節點替換(步驟 4)
|
||
// - triplets + target=component|recipe=只查該庫
|
||
// - query + target=名字搜尋,各自走**既有**機制(registry search/私庫 RECIPES/workflows/search)
|
||
cypherRouter.post('/cypher/search', async (c) => {
|
||
const body = await c.req.json() as { triplets?: unknown; mode?: unknown; target?: unknown; query?: unknown };
|
||
const rawTriplets = body?.triplets;
|
||
|
||
// ── target 驗證(component / recipe / workflow)─────────────────────────────
|
||
const target = typeof body?.target === 'string' ? body.target : undefined;
|
||
if (target !== undefined && !VALID_TARGETS.has(target)) {
|
||
return c.json({ error: `target 只接受 component/recipe/workflow,收到「${target}」` }, 400);
|
||
}
|
||
|
||
// ── query 名字搜尋分支(需 target)──────────────────────────────────────────
|
||
const query = typeof body?.query === 'string' ? body.query.trim() : '';
|
||
if (query) {
|
||
if (!target) {
|
||
return c.json({ error: '給 query 必須同時給 target(component/recipe/workflow),指明要搜哪個庫' }, 400);
|
||
}
|
||
const apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
|
||
const r = await searchByTarget(target as 'component' | 'recipe' | 'workflow', query, c.env, apiKey);
|
||
if (!r.ok) return c.json({ error: r.error }, r.status);
|
||
return c.json(r.body);
|
||
}
|
||
|
||
if (!Array.isArray(rawTriplets) || rawTriplets.length === 0) {
|
||
return c.json({ error: 'triplets 必須為非空字串陣列(或給 query + target 做名字搜尋)' }, 400);
|
||
}
|
||
|
||
// t158「部署≠發現」:mode=compile=純編圖(安裝器/acr push 的複製路徑,零存在性查詢);
|
||
// 預設 discover=誠實查詢(AI 問「有沒有」的既有契約,not_found+指路照舊)。
|
||
const mode = body?.mode === 'compile' ? 'compile' : 'discover';
|
||
|
||
// target 限庫只屬於 discover(compile=純複製,不查任何庫,target 無意義)
|
||
if (target && mode === 'compile') {
|
||
return c.json({ error: 'mode=compile(複製路徑)不查庫,不接受 target;要指定搜尋對象請用 discover(預設)' }, 400);
|
||
}
|
||
// workflow 是名字搜尋,不參與三元組編圖——請帶 query
|
||
if (target === 'workflow') {
|
||
return c.json({ error: 'target=workflow 是名字搜尋,請改帶 { target: "workflow", query: "..." }(不吃 triplets)' }, 400);
|
||
}
|
||
|
||
try {
|
||
const now = new Date();
|
||
const timestamp = now.toISOString();
|
||
const versionId = `search-v1-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||
|
||
const result = await handleCypherSearch(rawTriplets, c.env, mode, target as 'component' | 'recipe' | undefined);
|
||
|
||
const response = {
|
||
version: versionId,
|
||
timestamp,
|
||
triplets: rawTriplets,
|
||
nodes: result.nodes,
|
||
cypher: result.cypher,
|
||
missing: result.missing,
|
||
};
|
||
|
||
return c.json(response);
|
||
} catch (err) {
|
||
const errMsg = err instanceof Error ? err.message : String(err);
|
||
return c.json({ error: errMsg }, 400);
|
||
}
|
||
});
|
||
|
||
// POST /cypher/execute — 三元組 → 一步執行(search + execute 合一)
|
||
cypherRouter.post('/cypher/execute', async (c) => {
|
||
const body = await c.req.json() as {
|
||
triplets?: unknown;
|
||
context?: Record<string, unknown>;
|
||
config?: Record<string, Record<string, unknown>>; // node_name → {component, ...params}
|
||
graph_id?: string;
|
||
graph_name?: string;
|
||
};
|
||
|
||
if (!Array.isArray(body?.triplets) || body.triplets.length === 0) {
|
||
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
|
||
}
|
||
|
||
const graphId = typeof body.graph_id === 'string' ? body.graph_id : `triplet-exec-${Date.now()}`;
|
||
const graphName = typeof body.graph_name === 'string' ? body.graph_name : 'Triplet Execution';
|
||
const now = new Date();
|
||
const timestamp = now.toISOString();
|
||
// 版本號格式:execute-v1-20260327-143022
|
||
const versionId = `execute-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 apiKey = c.req.header('X-Arcrun-API-Key') ?? undefined;
|
||
|
||
try {
|
||
const result = await handleCypherExecute(
|
||
body.triplets as unknown[],
|
||
body.context,
|
||
graphId,
|
||
graphName,
|
||
body.config,
|
||
c.env,
|
||
(p) => c.executionCtx.waitUntil(p),
|
||
apiKey,
|
||
);
|
||
// 包裝成開發友善格式(execute 成功時)
|
||
const response = {
|
||
version: versionId,
|
||
timestamp,
|
||
...result,
|
||
};
|
||
return c.json(response);
|
||
} catch (err) {
|
||
const errMsg = err instanceof Error ? err.message : String(err);
|
||
try {
|
||
const parsed = JSON.parse(errMsg);
|
||
const response = {
|
||
version: versionId,
|
||
timestamp,
|
||
...parsed,
|
||
};
|
||
return c.json(response, 500);
|
||
} catch {
|
||
return c.json({ version: versionId, timestamp, success: false, error: errMsg, duration_ms: 0 }, 500);
|
||
}
|
||
}
|
||
});
|