feat(workflow-discovery): /cypher/search 改為真查 registry——修「查詢回假信號」

📋 SDD:workflow-discovery(本 commit 同時執行 D35 交接:portal-auth 26/26 完成 → closed
   superseded_by workflow-discovery;workflow-discovery paused → active。單一活性已驗=1 份)
🎯 對應 task:3.x 搜尋端誠實化(CP2-B)

病灶(leo 2026-07-30 定性「腹語術」):
search-nodes.ts 無條件回 status:'found'、missingNodes 永遠 []——
型別宣告了 'missing' 但程式碼從不使用。實測「完全不存在的東西xyz」也回 found。
⇒ AI 拿到假信號 → 以為零件存在 → 部署才發現沒有 → 改寫 code
⇒ 正式 workflow 只用 2 個零件、8 個 code 節點含 if×61。

修法:
- 查 registry 判真實存在(走 HTTP,守 D28 禁新增 service binding;
  URL 用既有 wasmWorkerUrl() 慣例組,不自創)
- found 時附 input_schema/success_rate/stability
  ⇒ AI 才填得出 payload、才看得到「測過幾次」(leo:AI 只要填 payload)
- 查不通回 'unknown' 而非 'missing'——**誠實限制**:
  不能因查詢失敗就宣告零件不存在(那會讓 AI 誤判而重寫 code)
- missing 真的回傳出去(原本寫死 [])

⚠️ 實測發現 registry **沒有列表端點**(GET /components → 404,只有 /components/<id>)
⇒ 改為逐個查(節點數通常 <10、5s timeout)。補列表端點後可改抓一次=CP2-B 待辦。

驗:tsc 零錯誤;vitest 9 failed/179 passed=**與改動前 stash 對帳完全相同**(既有債非本次造成)。
 待部署到實例後跑 arcrun-usable/verify.sh 驗 01 那組轉綠。
This commit is contained in:
2026-07-30 20:41:53 +08:00
parent 646e689b65
commit 5cadc60e36
5 changed files with 127 additions and 20 deletions
@@ -17,10 +17,12 @@ export async function handleCypherSearch(
throw new Error('無法解析任何節點');
}
const { nodeResults } = searchNodes(parsed);
// 2026-07-30:查 registry 判真實存在(workflow-discovery)。
// `missing` 以前寫死 [],等於告訴 AI「什麼都有」——那是「腹語術」的入口。
const { nodeResults, missingNodes } = await searchNodes(parsed, undefined, env);
const graph = buildExecutionGraph(parsed, nodeResults, 'cypher-search-result', 'Cypher Search Result');
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: [] };
return { nodes: nodeResults, cypher: { nodes: graph.nodes, edges: graph.edges }, missing: missingNodes };
}
export async function handleCypherExecute(
@@ -50,7 +52,7 @@ export async function handleCypherExecute(
throw new Error('無法解析任何節點');
}
const { nodeResults } = searchNodes(parsed, config);
const { nodeResults } = await searchNodes(parsed, config, env);
const graph = buildExecutionGraph(parsed, nodeResults, graphId, graphName, config);
const parseResult = graphSchema.safeParse(graph);
+116 -13
View File
@@ -1,27 +1,53 @@
import type { ParsedTriplets, NodeRole } from './triplet-parser';
import { resolveNodeRole } from './triplet-parser';
import { wasmWorkerUrl } from '../lib/component-loader';
export type NodeStatus = 'found' | 'missing' | 'unknown';
export type NodeInfo = {
status: NodeStatus;
componentId?: string;
type: NodeRole;
/** 零件契約(found 時附上,讓 AI 知道怎麼填 payload)。 */
input_schema?: unknown;
/** 成功率(found 時附上,讓「被測過幾次」看得見)。 */
success_rate?: number;
stability?: string;
/** missing 時給的相近零件建議(避免 AI 只知道「沒有」卻不知道該用什麼)。 */
suggestions?: string[];
};
export type SearchResult = {
nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }>;
nodeResults: Record<string, NodeInfo>;
missingNodes: string[];
};
/**
* 對所有節點進行解析,確認每個節點對應的零件 ID。
* 對所有節點進行解析,確認每個節點對應的零件 ID 與**是否真的存在**
*
* 注意:此步驟只做靜態解析,不做遠端查找
* 零件是否真的存在由 component-loader 在執行時決定(Service Binding / KV / URL)。
* ⚠️ 2026-07-30 改為會查 registryworkflow-discovery task 3.x
*
* 優先序:
* 1. Input/Output 角色:自動標記,componentId = 小寫節點名稱
* 2. config[nodeName].component 已指定:使用 config 提供的 componentId
* 3. 其他:componentId = 節點名稱(交給 component-loader 在執行時解析)
* 改之前的行為(病灶):無條件回 `status: 'found'`、`missingNodes` 永遠是空陣列——
* 型別雖宣告了 `'missing'` 但程式碼從不使用。實測「完全不存在的東西xyz」也回 found。
*
* 為什麼這是嚴重問題(leo 2026-07-30 定性「腹語術」):
* AI 寫意圖 → 查詢回「都 found」(假信號)→ 實際零件不存在
* → 部署/執行才發現 → 最快的修法是改寫成 `code` 節點自己寫 JS
* → 於是正式 workflow 只用 2 個零件、8 個 code 節點含 if×61
* ⇒ 「零件被測過 1000 次所以 AI 只要填 payload」的價值完全落空。
*
* 誠實限制:查不到 registry(未部署/網路失敗)時回 `'unknown'` 而不是 `'missing'`——
* 不能因為查詢失敗就宣告零件不存在(那會讓 AI 誤判而重寫 code,正是要避免的事)。
*/
export function searchNodes(
export async function searchNodes(
parsed: ParsedTriplets,
config?: Record<string, Record<string, unknown>>,
): SearchResult {
const nodeResults: Record<string, { status: 'found' | 'missing'; componentId?: string; type: NodeRole }> = {};
env?: { WORKER_SUBDOMAIN?: string },
): Promise<SearchResult> {
const nodeResults: Record<string, NodeInfo> = {};
const missingNodes: string[] = [];
const sub = env?.WORKER_SUBDOMAIN;
for (const nodeName of parsed.nodeNames) {
const role = resolveNodeRole(nodeName, parsed);
@@ -33,8 +59,85 @@ export function searchNodes(
const configComponent = config?.[nodeName]?.component as string | undefined;
const componentId = configComponent ?? nodeName;
nodeResults[nodeName] = { status: 'found', componentId, type: role };
// config 明確給了 component(多半是安裝器代入的 worker URL 或既有 workflow
// → 不判 missing。這條路徑的存在性由 component-loader 在執行時決定(原行為)。
if (configComponent) {
nodeResults[nodeName] = { status: 'found', componentId, type: role };
continue;
}
if (!sub) {
nodeResults[nodeName] = { status: 'unknown', componentId, type: role };
continue;
}
const q = await fetchComponent(sub, componentId);
if (!q.ok) {
// registry 查不通(未部署/網路失敗)⇒ 誠實回 unknown。
// **不能誤判 missing**——那會讓 AI 以為零件不存在而重寫 code,正是要避免的事。
nodeResults[nodeName] = { status: 'unknown', componentId, type: role };
continue;
}
if (q.entry) {
nodeResults[nodeName] = {
status: 'found',
componentId,
type: role,
input_schema: q.entry.input_schema,
success_rate: q.entry.success_rate,
stability: q.entry.stability,
};
continue;
}
nodeResults[nodeName] = { status: 'missing', componentId, type: role };
missingNodes.push(nodeName);
}
return { nodeResults, missingNodes: [] };
return { nodeResults, missingNodes };
}
type CatalogEntry = {
input_schema?: unknown;
success_rate?: number;
stability?: string;
};
/**
* 查單一零件是否存在於 registry。
*
* ⚠️ 為什麼逐個查而非抓整份目錄:registry **沒有列表端點**
* (實測 `GET /components` → 404,只有 `GET /components/<id>`)。
* 這是 CP2-B 記載的缺口(「修 /components 404」)——補了列表端點後可改為抓一次。
* 現階段逐個查:節點數通常 <10,且有 5s timeout,可接受。
*
* 回傳 `null` 代表「查不到 registry 或該零件不存在」,由呼叫端區分:
* 整體查不通 → `unknown`;查得通但這顆沒有 → `missing`。
*/
async function fetchComponent(
subdomain: string,
id: string,
): Promise<{ ok: boolean; entry?: CatalogEntry }> {
try {
const base = wasmWorkerUrl('registry', subdomain);
const res = await fetch(`${base}/components/${encodeURIComponent(id)}`, {
signal: AbortSignal.timeout(5000),
});
if (res.status === 404) return { ok: true }; // registry 活著,但沒這顆
if (!res.ok) return { ok: false };
const body = (await res.json()) as { success?: boolean; data?: Record<string, unknown> };
if (body.success === false) return { ok: true }; // 同上:回「零件不存在」
const d = body.data ?? (body as unknown as Record<string, unknown>);
return {
ok: true,
entry: {
input_schema: d.input_schema,
success_rate: typeof d.success_rate === 'number' ? d.success_rate : undefined,
stability: typeof d.stability === 'string' ? d.stability : undefined,
},
};
} catch {
return { ok: false };
}
}
@@ -14,7 +14,7 @@ export async function resolveWebhookGraph(
const parsed = parseTriplets(body.triplets as unknown[]);
if (!parsed) return { resolvedGraph: {}, error: '無法解析 triplets' };
const { nodeResults } = searchNodes(parsed);
const { nodeResults } = await searchNodes(parsed);
const graphId = `webhook-${Date.now()}`;
const graphName = description || `Webhook ${new Date().toISOString()}`;
@@ -1,6 +1,8 @@
---
status: active
superseded_by: ""
status: closed
superseded_by: "workflow-discovery"
closed_at: "2026-07-30"
closed_reason: "26/26 任務全完成、0 未完成待搬;封測 portal 多人授權已上線"
---
# portal-auth — DesignRAG Portal 多人授權)
@@ -1,5 +1,5 @@
---
status: paused
status: active
superseded_by: ""
---