提交
This commit is contained in:
1 parent
086c280e5d
commit
9de85591bc
1 file changed
+319
@@ -0,0 +1,319 @@
|
||||
// src/roundtable-planning/lib/flow-graph.test.ts
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// src/roundtable-planning/lib/flow-graph.ts
|
||||
var FLOW_NODE_TYPE_META = {
|
||||
start: { label: "\u5F00\u59CB", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
|
||||
end: { label: "\u7ED3\u675F", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
|
||||
stage: { label: "\u9636\u6BB5", bg: "#eff6ff", border: "#60a5fa", text: "#1d4ed8", dot: "#3b82f6" },
|
||||
task: { label: "\u4EFB\u52A1", bg: "#ecfeff", border: "#22d3ee", text: "#0e7490", dot: "#06b6d4" },
|
||||
decision: { label: "\u5224\u65AD", bg: "#fffbeb", border: "#fbbf24", text: "#b45309", dot: "#f59e0b" },
|
||||
risk: { label: "\u98CE\u9669", bg: "#fef2f2", border: "#f87171", text: "#b91c1c", dot: "#ef4444" },
|
||||
deliverable: { label: "\u4EA4\u4ED8\u7269", bg: "#f0fdf4", border: "#4ade80", text: "#15803d", dot: "#22c55e" },
|
||||
role: { label: "\u89D2\u8272", bg: "#faf5ff", border: "#c084fc", text: "#7e22ce", dot: "#a855f7" }
|
||||
};
|
||||
var VALID_TYPES = new Set(Object.keys(FLOW_NODE_TYPE_META));
|
||||
var FLOW_FENCE_OPEN = "```flow-json";
|
||||
function collectObjects(text, key) {
|
||||
const keyIdx = text.indexOf(key);
|
||||
if (keyIdx < 0) return [];
|
||||
const arrStart = text.indexOf("[", keyIdx);
|
||||
if (arrStart < 0) return [];
|
||||
const out = [];
|
||||
let depth = 0;
|
||||
let objStart = -1;
|
||||
let inStr = false;
|
||||
let esc = false;
|
||||
for (let i = arrStart + 1; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (inStr) {
|
||||
if (esc) esc = false;
|
||||
else if (ch === "\\") esc = true;
|
||||
else if (ch === '"') inStr = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inStr = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "{") {
|
||||
if (depth === 0) objStart = i;
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "}") {
|
||||
depth--;
|
||||
if (depth === 0 && objStart >= 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(text.slice(objStart, i + 1));
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
out.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
objStart = -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "]" && depth === 0) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function asString(v) {
|
||||
return typeof v === "string" ? v.trim() : "";
|
||||
}
|
||||
function toNode(raw) {
|
||||
const id = asString(raw.id);
|
||||
const label = asString(raw.label) || asString(raw.name);
|
||||
if (!id || !label) return null;
|
||||
const rawType = asString(raw.type).toLowerCase();
|
||||
const type = VALID_TYPES.has(rawType) ? rawType : "task";
|
||||
const detail = asString(raw.detail) || asString(raw.description) || void 0;
|
||||
return { id, label, type, detail };
|
||||
}
|
||||
function edgesFromNode(raw, nodeId) {
|
||||
const from = raw.from ?? raw.prev;
|
||||
if (!Array.isArray(from)) return [];
|
||||
const out = [];
|
||||
for (const entry of from) {
|
||||
if (typeof entry === "string" && entry.trim()) {
|
||||
out.push({ from: entry.trim(), to: nodeId });
|
||||
} else if (entry && typeof entry === "object") {
|
||||
const o = entry;
|
||||
const fromId = asString(o.id) || asString(o.from);
|
||||
if (!fromId) continue;
|
||||
const label = asString(o.label) || void 0;
|
||||
out.push({ from: fromId, to: nodeId, label });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function toEdge(raw) {
|
||||
const from = asString(raw.from) || asString(raw.source);
|
||||
const to = asString(raw.to) || asString(raw.target);
|
||||
if (!from || !to) return null;
|
||||
const label = asString(raw.label) || void 0;
|
||||
return { from, to, label };
|
||||
}
|
||||
function extractFlowGraph(text) {
|
||||
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
|
||||
if (start < 0) return null;
|
||||
let body = text.slice(start + FLOW_FENCE_OPEN.length);
|
||||
const close = body.indexOf("```");
|
||||
if (close >= 0) body = body.slice(0, close);
|
||||
const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body);
|
||||
let title = "";
|
||||
if (titleMatch) {
|
||||
try {
|
||||
title = JSON.parse(`"${titleMatch[1]}"`);
|
||||
} catch {
|
||||
title = titleMatch[1];
|
||||
}
|
||||
}
|
||||
const rawNodes = collectObjects(body, '"nodes"');
|
||||
const nodes = [];
|
||||
const seen = /* @__PURE__ */ new Set();
|
||||
const edges = [];
|
||||
for (const raw of rawNodes) {
|
||||
const node = toNode(raw);
|
||||
if (!node || seen.has(node.id)) continue;
|
||||
seen.add(node.id);
|
||||
nodes.push(node);
|
||||
edges.push(...edgesFromNode(raw, node.id));
|
||||
}
|
||||
for (const raw of collectObjects(body, '"edges"')) {
|
||||
const edge = toEdge(raw);
|
||||
if (edge) edges.push(edge);
|
||||
}
|
||||
if (nodes.length === 0) return null;
|
||||
const edgeKeys = /* @__PURE__ */ new Set();
|
||||
const validEdges = edges.filter((e) => {
|
||||
if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false;
|
||||
const key = `${e.from}->${e.to}`;
|
||||
if (edgeKeys.has(key)) return false;
|
||||
edgeKeys.add(key);
|
||||
return true;
|
||||
});
|
||||
return { title, nodes, edges: validEdges };
|
||||
}
|
||||
var FLOW_INTENT_PATTERNS = [
|
||||
"\u6D41\u7A0B\u56FE",
|
||||
"\u53EF\u89C6\u5316\u6D41\u7A0B",
|
||||
"\u7ED3\u6784\u5316\u8F93\u51FA",
|
||||
"\u7ED3\u6784\u5316\u6570\u636E",
|
||||
"\u7ED3\u6784\u5316\u6D41\u7A0B",
|
||||
"\u7ED3\u6784\u5316\u603B\u7ED3",
|
||||
"\u683C\u5F0F\u5316\u8F93\u51FA",
|
||||
"\u683C\u5F0F\u5316\u603B\u7ED3",
|
||||
"flow-json",
|
||||
"flowchart"
|
||||
];
|
||||
function detectFlowIntent(text) {
|
||||
if (!text) return false;
|
||||
const lower = text.toLowerCase();
|
||||
return FLOW_INTENT_PATTERNS.some((p) => lower.includes(p));
|
||||
}
|
||||
function stripFlowFence(text, streaming) {
|
||||
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
|
||||
if (start < 0) return text;
|
||||
const after = text.slice(start + FLOW_FENCE_OPEN.length);
|
||||
const close = after.indexOf("```");
|
||||
const tail = close >= 0 ? after.slice(close + 3) : "";
|
||||
const placeholder = close < 0 && streaming ? "\n\n> \u{1F4CA} \u6B63\u5728\u8F93\u51FA\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E\uFF0C\u6D41\u7A0B\u56FE\u753B\u5E03\u540C\u6B65\u7ED8\u5236\u4E2D\u2026" : "\n\n> \u{1F4CA} \u5DF2\u751F\u6210\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E\uFF0C\u53EF\u5728\u53F3\u4FA7\u300C\u6D41\u7A0B\u56FE\u300D\u753B\u5E03\u67E5\u770B\uFF08\u70B9\u51FB\u8282\u70B9\u770B\u8BE6\u60C5\uFF09\u3002";
|
||||
return `${text.slice(0, start).trimEnd()}${placeholder}${tail}`;
|
||||
}
|
||||
var X_GAP = 224;
|
||||
var Y_GAP = 132;
|
||||
function layoutFlowGraph(data) {
|
||||
const preds = /* @__PURE__ */ new Map();
|
||||
for (const e of data.edges) {
|
||||
const list = preds.get(e.to);
|
||||
if (list) list.push(e.from);
|
||||
else preds.set(e.to, [e.from]);
|
||||
}
|
||||
const memo = /* @__PURE__ */ new Map();
|
||||
const visiting = /* @__PURE__ */ new Set();
|
||||
const layerOf = (id) => {
|
||||
const cached = memo.get(id);
|
||||
if (cached !== void 0) return cached;
|
||||
if (visiting.has(id)) return 0;
|
||||
visiting.add(id);
|
||||
const ps = preds.get(id) ?? [];
|
||||
const layer = ps.length === 0 ? 0 : Math.max(...ps.map(layerOf)) + 1;
|
||||
visiting.delete(id);
|
||||
memo.set(id, layer);
|
||||
return layer;
|
||||
};
|
||||
const byLayer = /* @__PURE__ */ new Map();
|
||||
for (const n of data.nodes) {
|
||||
const layer = layerOf(n.id);
|
||||
const list = byLayer.get(layer);
|
||||
if (list) list.push(n);
|
||||
else byLayer.set(layer, [n]);
|
||||
}
|
||||
const pos = /* @__PURE__ */ new Map();
|
||||
for (const [layer, nodes] of byLayer) {
|
||||
const rowWidth = (nodes.length - 1) * X_GAP;
|
||||
nodes.forEach((n, i) => {
|
||||
pos.set(n.id, { x: i * X_GAP - rowWidth / 2, y: layer * Y_GAP });
|
||||
});
|
||||
}
|
||||
return data.nodes.map((n) => ({ ...n, ...pos.get(n.id) }));
|
||||
}
|
||||
|
||||
// src/roundtable-planning/lib/flow-graph.test.ts
|
||||
var FULL = [
|
||||
"\u62A5\u544A\u6B63\u6587\u2026\u2026",
|
||||
"```flow-json",
|
||||
'{"title":"\u65B9\u6848\u6267\u884C\u6D41\u7A0B","nodes":[',
|
||||
'{"id":"n1","label":"\u73AF\u5883\u8BC4\u4F30","type":"stage","detail":"\u8BC4\u4F30\u73B0\u573A\u73AF\u5883"},',
|
||||
'{"id":"n2","label":"\u7F16\u5236\u65B9\u6848","type":"task","from":["n1"]},',
|
||||
'{"id":"n3","label":"\u662F\u5426\u53EF\u884C","type":"decision","from":[{"id":"n2","label":"\u8BC4\u5BA1"}]}',
|
||||
"]}",
|
||||
"```"
|
||||
].join("\n");
|
||||
test("extractFlowGraph\uFF1A\u5B8C\u6574\u5757\u89E3\u6790\u51FA\u8282\u70B9/\u8FB9/\u6807\u9898", () => {
|
||||
const g = extractFlowGraph(FULL);
|
||||
assert.ok(g);
|
||||
assert.equal(g.title, "\u65B9\u6848\u6267\u884C\u6D41\u7A0B");
|
||||
assert.equal(g.nodes.length, 3);
|
||||
assert.equal(g.nodes[0].type, "stage");
|
||||
assert.deepEqual(g.edges, [
|
||||
{ from: "n1", to: "n2" },
|
||||
{ from: "n2", to: "n3", label: "\u8BC4\u5BA1" }
|
||||
]);
|
||||
});
|
||||
test("extractFlowGraph\uFF1A\u6D41\u5F0F\u534A\u622A\uFF08fence \u672A\u95ED\u5408 + \u5C3E\u5BF9\u8C61\u622A\u65AD\uFF09\u53EA\u53D6\u5B8C\u6574\u8282\u70B9", () => {
|
||||
const partial = [
|
||||
"\u6B63\u6587",
|
||||
"```flow-json",
|
||||
'{"title":"T","nodes":[',
|
||||
'{"id":"a","label":"A","type":"start"},',
|
||||
'{"id":"b","label":"B","type":"task","from":["a"]},',
|
||||
'{"id":"c","label":"C","ty'
|
||||
// 截断
|
||||
].join("\n");
|
||||
const g = extractFlowGraph(partial);
|
||||
assert.ok(g);
|
||||
assert.equal(g.nodes.length, 2);
|
||||
assert.deepEqual(g.edges, [{ from: "a", to: "b" }]);
|
||||
});
|
||||
test("extractFlowGraph\uFF1A\u672A\u77E5 type \u843D task\u3001\u91CD\u590D id \u53BB\u91CD\u3001\u60AC\u7A7A\u8FB9\u4E22\u5F03", () => {
|
||||
const text = [
|
||||
"```flow-json",
|
||||
'{"nodes":[',
|
||||
'{"id":"x","label":"X","type":"banana"},',
|
||||
'{"id":"x","label":"X-dup","type":"task"},',
|
||||
'{"id":"y","label":"Y","type":"risk","from":["x","ghost"]}',
|
||||
"]}",
|
||||
"```"
|
||||
].join("\n");
|
||||
const g = extractFlowGraph(text);
|
||||
assert.ok(g);
|
||||
assert.equal(g.nodes.length, 2);
|
||||
assert.equal(g.nodes[0].type, "task");
|
||||
assert.equal(g.nodes[0].label, "X");
|
||||
assert.deepEqual(g.edges, [{ from: "x", to: "y" }]);
|
||||
});
|
||||
test("extractFlowGraph\uFF1A\u65E0 fence / \u65E0\u5B8C\u6574\u8282\u70B9\u8FD4\u56DE null", () => {
|
||||
assert.equal(extractFlowGraph("\u666E\u901A\u56DE\u590D\uFF0C\u6CA1\u6709\u7ED3\u6784\u5316\u6570\u636E"), null);
|
||||
assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null);
|
||||
});
|
||||
test("extractFlowGraph\uFF1A\u517C\u5BB9\u72EC\u7ACB edges \u6570\u7EC4\uFF08source/target \u522B\u540D\uFF09", () => {
|
||||
const text = [
|
||||
"```flow-json",
|
||||
'{"nodes":[{"id":"a","label":"A"},{"id":"b","label":"B"}],',
|
||||
'"edges":[{"source":"a","target":"b","label":"\u8D70"}]}',
|
||||
"```"
|
||||
].join("\n");
|
||||
const g = extractFlowGraph(text);
|
||||
assert.ok(g);
|
||||
assert.deepEqual(g.edges, [{ from: "a", to: "b", label: "\u8D70" }]);
|
||||
});
|
||||
test("stripFlowFence\uFF1A\u95ED\u5408\u5757\u66FF\u6362\u4E3A\u5F15\u5BFC\u8BED\u5E76\u4FDD\u7559\u524D\u540E\u6587", () => {
|
||||
const out = stripFlowFence(FULL, false);
|
||||
assert.ok(out.includes("\u62A5\u544A\u6B63\u6587"));
|
||||
assert.ok(!out.includes("```flow-json"));
|
||||
assert.ok(out.includes("\u5DF2\u751F\u6210\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E"));
|
||||
});
|
||||
test("stripFlowFence\uFF1A\u672A\u95ED\u5408\uFF08\u6D41\u5F0F\u4E2D\uFF09\u7528\u300C\u6B63\u5728\u8F93\u51FA\u300D\u63AA\u8F9E", () => {
|
||||
const out = stripFlowFence('\u6B63\u6587\n```flow-json\n{"nodes":[', true);
|
||||
assert.ok(out.includes("\u6B63\u5728\u8F93\u51FA\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E"));
|
||||
assert.ok(!out.includes("```flow-json"));
|
||||
});
|
||||
test("layoutFlowGraph\uFF1A\u6309\u524D\u9A71\u5206\u5C42\uFF08\u6839 0 \u5C42\uFF0C\u5B50\u5C42\u9012\u589E\uFF09\uFF0C\u4FDD\u6301\u539F\u59CB\u987A\u5E8F", () => {
|
||||
const g = extractFlowGraph(FULL);
|
||||
const positioned = layoutFlowGraph(g);
|
||||
assert.equal(positioned[0].y, 0);
|
||||
assert.ok(positioned[1].y > positioned[0].y);
|
||||
assert.ok(positioned[2].y > positioned[1].y);
|
||||
assert.deepEqual(positioned.map((n) => n.id), ["n1", "n2", "n3"]);
|
||||
});
|
||||
test("detectFlowIntent\uFF1A\u660E\u786E\u77ED\u8BED\u547D\u4E2D\uFF08\u7F16\u6392\u63D0\u793A / \u8F93\u5165\u6846\u6D88\u606F\uFF09", () => {
|
||||
assert.ok(detectFlowIntent("\u6700\u7EC8\u603B\u7ED3\u8BF7\u7528\u7ED3\u6784\u5316\u8F93\u51FA"));
|
||||
assert.ok(detectFlowIntent("\u603B\u7ED3\u6210\u683C\u5F0F\u5316\u8F93\u51FA"));
|
||||
assert.ok(detectFlowIntent("\u7ED9\u6211\u753B\u4E00\u5F20\u6D41\u7A0B\u56FE"));
|
||||
assert.ok(detectFlowIntent("\u8F93\u51FA\u53EF\u89C6\u5316\u6D41\u7A0B"));
|
||||
assert.ok(detectFlowIntent("\u8BF7\u6309 flow-json \u5951\u7EA6\u8F93\u51FA"));
|
||||
assert.ok(detectFlowIntent("\u91CD\u70B9\u603B\u7ED3\u65B9\u6848\u8BBE\u8BA1\u5E2D\u4F4D\uFF0C\u6700\u540E\u7ED9\u7ED3\u6784\u5316\u6570\u636E"));
|
||||
});
|
||||
test("detectFlowIntent\uFF1A\u666E\u901A\u63AA\u8F9E\u4E0D\u8BEF\u89E6\u53D1", () => {
|
||||
assert.equal(detectFlowIntent("\u7528\u8868\u683C\u628A\u8981\u70B9\u7ED3\u6784\u5316\u5448\u73B0"), false);
|
||||
assert.equal(detectFlowIntent("\u628A\u6267\u884C\u8DEF\u7EBF\u5199\u5F97\u66F4\u7EC6\u4E00\u4E9B"), false);
|
||||
assert.equal(detectFlowIntent("\u9762\u5411\u9AD8\u7BA1\u6C47\u62A5\uFF0C\u63A7\u5236\u5728\u4E00\u9875\u5185"), false);
|
||||
assert.equal(detectFlowIntent(""), false);
|
||||
assert.equal(detectFlowIntent(null), false);
|
||||
assert.equal(detectFlowIntent(void 0), false);
|
||||
});
|
||||
test("layoutFlowGraph\uFF1A\u73AF\u4E0D\u6B7B\u5FAA\u73AF", () => {
|
||||
const text = [
|
||||
"```flow-json",
|
||||
'{"nodes":[{"id":"a","label":"A","from":["b"]},{"id":"b","label":"B","from":["a"]}]}',
|
||||
"```"
|
||||
].join("\n");
|
||||
const g = extractFlowGraph(text);
|
||||
const positioned = layoutFlowGraph(g);
|
||||
assert.equal(positioned.length, 2);
|
||||
positioned.forEach((n) => assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y)));
|
||||
});
|
||||
Reference in new issue
Block a user