/** * workflow.yaml 解析與三元組驗證 */ import yaml from 'js-yaml'; import { readFileSync } from 'node:fs'; export interface WorkflowYaml { name: string; description?: string; flow: string[]; config?: Record>; } export interface ParsedTriplet { subject: string; relation: string; object: string; } /** 合法關係詞(拒絕 PIPE)*/ const VALID_RELATIONS = new Set([ '完成後', '失敗時', '對每個', '條件滿足時', 'ON_SUCCESS', 'ON_FAIL', 'FOREACH', 'IF', 'ON_CLICK', 'CALLS_SUBFLOW', ]); const BANNED_RELATIONS = new Set(['PIPE']); export function loadWorkflowYaml(filePath: string): WorkflowYaml { const raw = readFileSync(filePath, 'utf8'); const doc = yaml.load(raw) as WorkflowYaml; if (!doc.name) throw new Error('workflow.yaml 缺少 name 欄位'); if (!Array.isArray(doc.flow) || doc.flow.length === 0) { throw new Error('workflow.yaml 的 flow 欄位必須為非空陣列'); } return doc; } export function parseTriplets(flow: string[]): ParsedTriplet[] { const triplets: ParsedTriplet[] = []; for (const line of flow) { const parts = line.split('>>').map(s => s.trim()); if (parts.length !== 3) { throw new Error( `三元組格式錯誤:「${line}」\n` + `正確格式:「A >> 關係詞 >> B」` ); } const [subject, relation, object] = parts; triplets.push({ subject, relation, object }); } return triplets; } export function validateRelations(triplets: ParsedTriplet[]): void { for (const t of triplets) { if (BANNED_RELATIONS.has(t.relation)) { throw new Error( `不允許使用關係詞「${t.relation}」。\n` + `「PIPE」已棄用,請改用「完成後」或「ON_SUCCESS」。` ); } // 容許 FOREACH iterator 命名變體:「對每個 paragraph」/「FOREACH item」 // graph-builder.ts 已支援這個 regex(commit e8fca33 2026-05-07) const foreachMatch = t.relation.match(/^(?:對每個|FOREACH)\s+\w+$/i); if (foreachMatch) continue; if (!VALID_RELATIONS.has(t.relation)) { throw new Error( `未知關係詞「${t.relation}」。\n` + `合法關係詞:${[...VALID_RELATIONS].join('、')}\n` + `(FOREACH 支援 iterator 命名:「對每個 X」/「FOREACH X」)` ); } } } export function getNodeNames(triplets: ParsedTriplet[]): string[] { const nodes = new Set(); for (const t of triplets) { nodes.add(t.subject); nodes.add(t.object); } return [...nodes]; }