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>
This commit is contained in:
@@ -55,10 +55,12 @@ export async function cmdPush(filePath: string): Promise<void> {
|
|||||||
const searchSpinner = ora('取得執行圖').start();
|
const searchSpinner = ora('取得執行圖').start();
|
||||||
let graph: unknown;
|
let graph: unknown;
|
||||||
try {
|
try {
|
||||||
|
// t158「部署≠發現」(leo:「這裡只是複製工作流的 data 過去,沒有要在這裡驗證」):
|
||||||
|
// push=複製路徑,帶 mode:compile 純編圖——寫錯的 workflow 照樣部署,錯在執行時現形。
|
||||||
const res = await fetch(`${executorUrl}/cypher/search`, {
|
const res = await fetch(`${executorUrl}/cypher/search`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ triplets: workflow.flow }),
|
body: JSON.stringify({ triplets: workflow.flow, mode: 'compile' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
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[] };
|
const data = await res.json() as { cypher: { nodes: unknown[]; edges: unknown[] }; missing: string[] };
|
||||||
if (data.missing?.length > 0) {
|
// t158:push 不看 missing(compile 模式亦恆空)——存在性由執行時 component-loader 決定;
|
||||||
searchSpinner.fail(chalk.red(`以下零件不存在:${data.missing.join(', ')}\n執行 acr parts 查看可用零件。`));
|
// 要「先問有沒有」用 acr validate/MCP 查詢(discover 路徑)。
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 附上 id / name,並將 workflow.config 套入節點(componentId + data)
|
// 附上 id / name,並將 workflow.config 套入節點(componentId + data)
|
||||||
const rawGraph = data.cypher as { nodes: Array<{ id: string; componentId?: string; data?: Record<string, unknown> }>; edges: unknown[] };
|
const rawGraph = data.cypher as { nodes: Array<{ id: string; componentId?: string; data?: Record<string, unknown> }>; edges: unknown[] };
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import { graphSchema } from '../lib/schemas';
|
|||||||
import { createComponentLoader } from '../lib/component-loader';
|
import { createComponentLoader } from '../lib/component-loader';
|
||||||
import { writeEvaluation, updateComponentStats } from './execution-evaluator';
|
import { writeEvaluation, updateComponentStats } from './execution-evaluator';
|
||||||
import { parseTriplets } from './triplet-parser';
|
import { parseTriplets } from './triplet-parser';
|
||||||
import { searchNodes } from './search-nodes';
|
import { searchNodes, type SearchMode } from './search-nodes';
|
||||||
import { buildExecutionGraph } from './graph-builder';
|
import { buildExecutionGraph } from './graph-builder';
|
||||||
|
|
||||||
export async function handleCypherSearch(
|
export async function handleCypherSearch(
|
||||||
triplets: unknown[],
|
triplets: unknown[],
|
||||||
env: Bindings,
|
env: Bindings,
|
||||||
|
mode: SearchMode = 'discover',
|
||||||
): Promise<{ nodes: Record<string, unknown>; cypher: unknown; missing: string[] }> {
|
): Promise<{ nodes: Record<string, unknown>; cypher: unknown; missing: string[] }> {
|
||||||
const parsed = parseTriplets(triplets);
|
const parsed = parseTriplets(triplets);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
@@ -19,7 +20,12 @@ export async function handleCypherSearch(
|
|||||||
|
|
||||||
// 2026-07-30:查 registry 判真實存在(workflow-discovery)。
|
// 2026-07-30:查 registry 判真實存在(workflow-discovery)。
|
||||||
// `missing` 以前寫死 [],等於告訴 AI「什麼都有」——那是「腹語術」的入口。
|
// `missing` 以前寫死 [],等於告訴 AI「什麼都有」——那是「腹語術」的入口。
|
||||||
const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env);
|
//
|
||||||
|
// t158(07-31 迴歸修復,leo:「這裡只是複製一些工作流的 data 過去,沒有要在這裡驗證」):
|
||||||
|
// 誠實化只屬於 **discover**(AI 問「有沒有」);**compile**(部署/推送的複製路徑)
|
||||||
|
// 純編圖零查詢——那本來就是既有設計(workflows.json=打包期預編的搬運),
|
||||||
|
// 5cadc60 起誠實化漏進複製路徑=迴歸(冷實例 8 節點 25.7s、安裝器 timeout 炸)。
|
||||||
|
const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env, mode);
|
||||||
|
|
||||||
const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result');
|
const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result');
|
||||||
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: missingNodes };
|
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: missingNodes };
|
||||||
@@ -52,7 +58,9 @@ export async function handleCypherExecute(
|
|||||||
throw new Error('無法解析任何節點');
|
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 graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName, config);
|
||||||
const parseResult = graphSchema.safeParse(graph);
|
const parseResult = graphSchema.safeParse(graph);
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import type { RecipeDefinition } from '../routes/recipes';
|
|||||||
* `not_found` 而非 `missing`:欄位契約以頂層機械考
|
* `not_found` 而非 `missing`:欄位契約以頂層機械考
|
||||||
* `system-dev/docs/3-specs/arcrun-usable/verify.sh` 為準(01 組 grep `not_found`)。
|
* `system-dev/docs/3-specs/arcrun-usable/verify.sh` 為準(01 組 grep `not_found`)。
|
||||||
*/
|
*/
|
||||||
export type NodeStatus = 'found' | 'not_found' | 'unknown';
|
/** `unchecked`=compile 模式的誠實標記:沒查、不知道有沒有(≠found 的假信號)。 */
|
||||||
|
export type NodeStatus = 'found' | 'not_found' | 'unknown' | 'unchecked';
|
||||||
|
|
||||||
export type NodeInfo = {
|
export type NodeInfo = {
|
||||||
status: NodeStatus;
|
status: NodeStatus;
|
||||||
@@ -40,6 +41,18 @@ export type SearchResult = {
|
|||||||
missingNodes: string[];
|
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 進來也相容)。 */
|
/** searchNodes 需要的環境子集(cypher-handlers 傳整份 Bindings 進來也相容)。 */
|
||||||
export type SearchNodesEnv = {
|
export type SearchNodesEnv = {
|
||||||
WORKER_SUBDOMAIN?: string;
|
WORKER_SUBDOMAIN?: string;
|
||||||
@@ -79,13 +92,46 @@ export async function searchNodes(
|
|||||||
parsed: ParsedTriplets,
|
parsed: ParsedTriplets,
|
||||||
config?: Record<string, Record<string, unknown>>,
|
config?: Record<string, Record<string, unknown>>,
|
||||||
env?: SearchNodesEnv,
|
env?: SearchNodesEnv,
|
||||||
|
mode: SearchMode = 'discover',
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
const nodeResults: Record<string, NodeInfo> = {};
|
const nodeResults: Record<string, NodeInfo> = {};
|
||||||
const missingNodes: string[] = [];
|
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 sub = env?.WORKER_SUBDOMAIN;
|
||||||
const registryBase = env?.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined);
|
const registryBase = env?.REGISTRY_BASE_URL ?? (sub ? wasmWorkerUrl('registry', sub) : undefined);
|
||||||
|
|
||||||
|
// ── discover 批次化(t158):兩庫各抓**一次**,之後全在記憶體內比對。────────
|
||||||
|
// 病史(07-31 stage 實測):舊版對每個 missing 節點各打「1 次逐顆查+最多 9 次
|
||||||
|
// 相似搜尋+一輪 recipe KV 掃描」⇒ 冷實例 8 節點 /cypher/search 25.7s,
|
||||||
|
// 安裝器 15s timeout 必炸。批次化後每 request 固定 1 次 catalog+1 次 recipe 清單。
|
||||||
|
const catalog = registryBase ? await fetchCatalog(registryBase) : { status: 'unreachable' as const, entries: [] };
|
||||||
|
const recipes = 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) {
|
for (const nodeName of parsed.nodeNames) {
|
||||||
const role = resolveNodeRole(nodeName, parsed);
|
const role = resolveNodeRole(nodeName, parsed);
|
||||||
|
|
||||||
@@ -107,34 +153,37 @@ export async function searchNodes(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!registryBase) {
|
// registry 完全查不通(未部署/網路失敗)⇒ 誠實回 unknown。
|
||||||
|
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
|
||||||
|
// 舊 registry 沒有 /catalog 端點(no_endpoint)→ 退回逐顆查(相容路徑)。
|
||||||
|
if (catalog.status === 'unreachable') {
|
||||||
nodeResults[nodeName] = { status: 'unknown', componentId, type: role };
|
nodeResults[nodeName] = { status: 'unknown', componentId, type: role };
|
||||||
continue;
|
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 ────────────────────────────────────────────────
|
// ── 第一庫:零件 catalog(記憶體)────────────────────────────────────────
|
||||||
const q = await fetchComponent(registryBase, componentId);
|
const hit = byId.get(componentId);
|
||||||
if (!q.ok) {
|
if (hit) {
|
||||||
// registry 查不通(未部署/網路失敗)⇒ 誠實回 unknown。
|
|
||||||
// **不能誤判 not_found**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
|
|
||||||
nodeResults[nodeName] = { status: 'unknown', componentId, type: role };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (q.entry) {
|
|
||||||
nodeResults[nodeName] = {
|
nodeResults[nodeName] = {
|
||||||
status: 'found',
|
status: 'found',
|
||||||
componentId,
|
componentId,
|
||||||
type: role,
|
type: role,
|
||||||
source: 'component',
|
source: 'component',
|
||||||
input_schema: q.entry.input_schema,
|
input_schema: hit.input_schema,
|
||||||
success_rate: q.entry.success_rate,
|
success_rate: typeof hit.success_rate === 'number' ? hit.success_rate : undefined,
|
||||||
stability: q.entry.stability,
|
stability: typeof hit.stability === 'string' ? hit.stability : undefined,
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 第二庫:recipe 庫(task 3.6——「說不出某 recipe 沒有」的病灶就在漏了這步)──
|
// ── 第二庫:recipe 清單(記憶體;canonical_id 精確比對)──────────────────
|
||||||
const recipe = env?.RECIPES ? await resolveRecipe(componentId, env.RECIPES) : null;
|
const recipe = recipes.find(r => r.canonical_id === componentId);
|
||||||
if (recipe) {
|
if (recipe) {
|
||||||
nodeResults[nodeName] = {
|
nodeResults[nodeName] = {
|
||||||
status: 'found',
|
status: 'found',
|
||||||
@@ -147,11 +196,9 @@ export async function searchNodes(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 兩庫都沒有 ⇒ not_found + 分型指路(task 3.7)+ 相近候選 ────────────
|
// ── 兩庫都沒有 ⇒ not_found + 分型指路(task 3.7)+ 相近候選(全記憶體)──
|
||||||
const [similarComponents, similarRecipes] = await Promise.all([
|
const similarComponents = similarFromCatalog(catalog.entries, nodeName);
|
||||||
searchSimilarComponents(registryBase, nodeName),
|
const similarRecipes = similarFromRecipes(recipes, nodeName);
|
||||||
env?.RECIPES ? searchSimilarRecipes(env.RECIPES, nodeName) : Promise.resolve([]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
nodeResults[nodeName] = {
|
nodeResults[nodeName] = {
|
||||||
status: 'not_found',
|
status: 'not_found',
|
||||||
@@ -167,6 +214,126 @@ export async function searchNodes(
|
|||||||
return { nodeResults, missingNodes };
|
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 與相似度共用同一份)。 */
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── 缺件分型(task 3.7)────────────────────────────────────────────────────────
|
// ── 缺件分型(task 3.7)────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// 分型判準(刻意用簡單可解釋的規則,不接 LLM——查詢端點要快、要可預測):
|
// 分型判準(刻意用簡單可解釋的規則,不接 LLM——查詢端點要快、要可預測):
|
||||||
|
|||||||
@@ -6,19 +6,23 @@ export const cypherRouter = new Hono<{ Bindings: Bindings }>();
|
|||||||
|
|
||||||
// POST /cypher/search — 三元組 → 解析節點 → 語意搜尋零件 → 回傳 Cypher JSON (開發友善格式)
|
// POST /cypher/search — 三元組 → 解析節點 → 語意搜尋零件 → 回傳 Cypher JSON (開發友善格式)
|
||||||
cypherRouter.post('/cypher/search', async (c) => {
|
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 };
|
||||||
const rawTriplets = body?.triplets;
|
const rawTriplets = body?.triplets;
|
||||||
|
|
||||||
if (!Array.isArray(rawTriplets) || rawTriplets.length === 0) {
|
if (!Array.isArray(rawTriplets) || rawTriplets.length === 0) {
|
||||||
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
|
return c.json({ error: 'triplets 必須為非空字串陣列' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// t158「部署≠發現」:mode=compile=純編圖(安裝器/acr push 的複製路徑,零存在性查詢);
|
||||||
|
// 預設 discover=誠實查詢(AI 問「有沒有」的既有契約,not_found+指路照舊)。
|
||||||
|
const mode = body?.mode === 'compile' ? 'compile' : 'discover';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const timestamp = now.toISOString();
|
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 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);
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
version: versionId,
|
version: versionId,
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ function computeScore(v: Record<string, unknown>): number {
|
|||||||
return successRate * speedScore * Math.log(callCount + 2);
|
return successRate * speedScore * Math.log(callCount + 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toComponentRecord(v: Record<string, unknown>): ComponentRecord {
|
export function toComponentRecord(v: Record<string, unknown>): ComponentRecord {
|
||||||
return {
|
return {
|
||||||
component_hash_id: String(v.component_hash_id ?? ''),
|
component_hash_id: String(v.component_hash_id ?? ''),
|
||||||
canonical_id: String(v.canonical_id ?? ''),
|
canonical_id: String(v.canonical_id ?? ''),
|
||||||
|
|||||||
@@ -5,10 +5,34 @@
|
|||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Bindings } from '../types';
|
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 }>();
|
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)
|
// 語意搜尋(必須在 /:id 之前,避免 "search" 被當作 id)
|
||||||
app.get('/search', async c => {
|
app.get('/search', async c => {
|
||||||
const q = c.req.query('q');
|
const q = c.req.query('q');
|
||||||
|
|||||||
Reference in New Issue
Block a user