Files
Arcrun/cypher-executor/src/actions/triplet-parser.ts
T
uncle6me-web 1794072179 3.6/3.7 查詢誠實化:兩庫都查、缺件回 not_found+分型指路 suggestion
📋 SDD:workflow-discovery task 3.6/3.7(leo 07-31 步驟 3 考題)
契約以頂層 arcrun-usable/verify.sh 01+03 組為準。

- 3.6 recipe 納入 /cypher/search:零件 registry 落空後查 RECIPES KV
  (resolveRecipe,執行鏈同一套解析);recipe found 附 source/description/endpoint
- 3.7 缺件分型指路:status 契約定 not_found(verify grep),suggestion 欄——
  名字含服務詞(google/telegram/…)=外部 API 樣貌 → 指 skill write_recipe;
  含計算詞(encrypt/hash/…)=計算原語樣貌 → 指 skill add_new_wasm_component 投稿 PR;
  判不出型誠實說判不出、兩條路都給。規則簡單可解釋(不接 LLM),判準在 code 註解
- 相近候選:not_found 附 similar_components/similar_recipes——全名搜不中就斷詞
  (ASCII 詞+中日韓 2-gram)打 registry /components/search,
  「判斷有沒有新資料」媒合到 if_control(leo:AI 不用知道零件存在)
- 修頭節點假 found:resolveNodeRole 把無入邊頭節點判 Input,searchNodes 對 Input
  無條件 found ⇒ 「aes_encrypt >> … >> code」的頭被掩蓋。改為只有字面
  input/trigger/…/output 名才短路(isVirtualIoName),真名字照查兩庫
- registry 查詢層補 input_schema/output_schema 透傳:KV 一直有存
  (indexOnlyComponent),toComponentRecord 丟掉 ⇒ /cypher/search 給不出
  「怎麼填 payload」。順手補 REGISTRY_BASE_URL 可選覆蓋(比照 KBDB_GRAPH_URL 慣例,
  本地 dev/self-hosted registry 掛別處時用)

驗:cypher-executor+registry tsc 全綠;vitest 9 failed/179 passed=與 5cadc60
基線完全相同(既有債非本次造成);本地 wrangler dev(cypher 指本地 registry,
KV 用 index-only 種入 20 份 repo 合約)跑頂層 verify.sh 01+03 組 9/9 全綠,
含 telegram_send 種入後 recipe found 路徑實測。部署到實例後需對真環境重驗。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:56:15 +08:00

142 lines
5.0 KiB
TypeScript

import { SEMANTIC_EDGE_MAP, VALID_EDGE_TYPES } from '../lib/constants';
import type { EdgeType } from '../types';
export type ParsedTriplets = {
edges: Array<{ from: string; to: string; label: string }>;
nodeNames: Set<string>;
/** 出現在 from 但不出現在任何 to 的節點(事件源 / 起始點) */
sourceNodes: Set<string>;
/** 出現在 to 但不出現在任何 from 的節點(終點)*/
sinkNodes: Set<string>;
};
export type NodeRole = 'Input' | 'Component' | 'Output';
/**
* 解析後的零件 URI
* 支援格式:
* component://validate_json
* component://validate_json@stable
* component://validate_json@pinned:v1
* workflow://wf_save_to_db
* ui://u6u-btn
* style://glow-effect
*/
export interface ResolvedComponentId {
type: 'component' | 'workflow' | 'ui' | 'style';
canonicalId: string;
stability: 'floating' | 'stable' | 'pinned';
pinnedVersion?: string;
/** 原始 URI 字串 */
raw: string;
}
/** 解析零件 URI 協議 */
export function resolveComponentId(uri: string): ResolvedComponentId {
const raw = uri.trim();
// 解析協議前綴
let type: ResolvedComponentId['type'] = 'component';
let rest = raw;
if (raw.startsWith('component://')) {
type = 'component';
rest = raw.slice('component://'.length);
} else if (raw.startsWith('workflow://')) {
type = 'workflow';
rest = raw.slice('workflow://'.length);
} else if (raw.startsWith('ui://')) {
type = 'ui';
rest = raw.slice('ui://'.length);
} else if (raw.startsWith('style://')) {
type = 'style';
rest = raw.slice('style://'.length);
}
// 解析穩定性標籤
// component://id@stable
// component://id@pinned:v1
let canonicalId = rest;
let stability: ResolvedComponentId['stability'] = 'floating';
let pinnedVersion: string | undefined;
const atIdx = rest.indexOf('@');
if (atIdx > 0) {
canonicalId = rest.slice(0, atIdx);
const tag = rest.slice(atIdx + 1);
if (tag === 'stable') {
stability = 'stable';
} else if (tag.startsWith('pinned:')) {
stability = 'pinned';
pinnedVersion = tag.slice('pinned:'.length);
}
}
return { type, canonicalId, stability, pinnedVersion, raw };
}
/** 解析 triplets 字串陣列,回傳節點與邊的結構 */
export function parseTriplets(rawTriplets: unknown[]): ParsedTriplets | null {
const edges: Array<{ from: string; to: string; label: string }> = [];
const nodeNames = new Set<string>();
const fromSet = new Set<string>();
const toSet = new Set<string>();
for (const line of rawTriplets) {
if (typeof line !== 'string') continue;
const parts = line.split('>>').map((s: string) => s.trim());
if (parts.length !== 3) continue;
const [from, action, to] = parts;
edges.push({ from, to, label: action });
nodeNames.add(from);
nodeNames.add(to);
fromSet.add(from);
toSet.add(to);
}
if (nodeNames.size === 0) return null;
const sourceNodes = new Set([...fromSet].filter(n => !toSet.has(n)));
const sinkNodes = new Set([...toSet].filter(n => !fromSet.has(n)));
return { edges, nodeNames, sourceNodes, sinkNodes };
}
/** 保留字節點名稱 — 明確宣告為 Input 或 Output 端點 */
const INPUT_NAMES = new Set(['input', 'trigger', 'webhook', 'start']);
const OUTPUT_NAMES = new Set(['output', 'result', 'end', 'done']);
/**
* 是否為「虛擬 IO 節點名」(input/output 這類非零件的佔位節點)。
* searchNodes 用它決定存在性查詢的短路:**只有字面上是虛擬 IO 名**才免查——
* 位置上是頭節點但名字是真零件(例 `aes_encrypt >> ON_SUCCESS >> code` 的頭)
* 仍要查兩庫,否則缺件被角色掩蓋、又回到「假 found」(task 3.7 實測踩到)。
*/
export function isVirtualIoName(name: string): boolean {
const lower = name.toLowerCase();
return INPUT_NAMES.has(lower) || OUTPUT_NAMES.has(lower);
}
/** 根據節點在圖中的位置決定其 type
*
* 規則:
* - 名稱在 INPUT_NAMES → Input(無論位置)
* - 名稱在 OUTPUT_NAMES → Output(無論位置)
* - sourceNode(只出現在 from)且名稱不在 INPUT_NAMES → Component(例如 cron 作為觸發源)
* - sinkNode(只出現在 to)且名稱不在 OUTPUT_NAMES → Component(最常見情況:最後一個實際零件)
* - 其他中間節點 → Component
*/
export function resolveNodeRole(name: string, parsed: ParsedTriplets): NodeRole {
if (INPUT_NAMES.has(name.toLowerCase())) return 'Input';
if (OUTPUT_NAMES.has(name.toLowerCase())) return 'Output';
if (parsed.sourceNodes.has(name)) return 'Input';
return 'Component';
}
/** 將 edge label 轉換為合法 EdgeType
* 優先序:VALID_EDGE_TYPES(完整匹配)→ SEMANTIC_EDGE_MAP(語意別名)→ 預設 PIPE */
export function toEdgeType(label: string): EdgeType {
const upper = label.toUpperCase();
if (VALID_EDGE_TYPES.has(upper)) return upper as EdgeType;
return (SEMANTIC_EDGE_MAP[label] ?? SEMANTIC_EDGE_MAP[upper] ?? 'PIPE') as EdgeType;
}