Files
bifrost/ui/app/workspace/routing-rules/tree/views/graphBuilder.ts
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

487 lines
17 KiB
TypeScript

/**
* Converts a list of RoutingRules into a React Flow node/edge graph.
*
* Pipeline:
* rules → buildTrie → mergeSubtrees → collectDAGStructure → Dagre LR layout → buildGraph
*/
import { RoutingRule } from "@/lib/types/routingRules";
import dagre from "@dagrejs/dagre";
import type { Edge, Node } from "@xyflow/react";
import { evalChainCondition, expandCEL, normalizeCond } from "./celParser";
import {
COND_H,
COND_W,
DAGRE_MARGIN,
DAGRE_NODESEP,
DAGRE_RANKSEP,
RULE_H,
RULE_W,
SCOPE_CONFIG,
SRC_H,
SRC_W,
type ScopeKey,
} from "./constants";
// ─── Color mixing ──────────────────────────────────────────────────────────
function hexToRgb(hex: string): [number, number, number] {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex(r: number, g: number, b: number): string {
return "#" + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, "0")).join("");
}
/** Weighted-blend hex colours. weights default to equal if omitted. */
function blendColors(colors: string[], weights?: number[]): string {
if (colors.length === 1) return colors[0];
const w = weights ?? colors.map(() => 1);
const total = w.reduce((s, v) => s + v, 0);
const [r, g, b] = colors
.map((c, i) => hexToRgb(c).map((ch) => ch * (w[i] / total)) as [number, number, number])
.reduce(([ar, ag, ab], [cr, cg, cb]) => [ar + cr, ag + cg, ab + cb], [0, 0, 0]);
return rgbToHex(r, g, b);
}
// ─── Trie / DAG types ─────────────────────────────────────────────────────
export interface TrieNode {
id: string;
condition: string | null;
children: Map<string, TrieNode>;
terminals: RoutingRule[];
}
interface LNode {
id: string;
kind: "source" | "condition" | "rule" | "target";
data: any;
w: number;
h: number;
}
interface LEdge {
source: string;
target: string;
label?: string;
color?: string;
isChainBack?: boolean;
isChainWeak?: boolean;
sourceHandle?: string;
targetHandle?: string;
}
// ─── Trie construction ────────────────────────────────────────────────────
export function buildTrie(rules: RoutingRule[]): TrieNode {
let uid = 0;
const mkNode = (c: string | null): TrieNode => ({
id: c === null ? "root" : `n${++uid}`,
condition: c,
children: new Map(),
terminals: [],
});
const root = mkNode(null);
// Pre-collect all (rule, normalized-path) pairs so we can compute frequencies.
const allPaths: { rule: RoutingRule; path: string[] }[] = [];
for (const rule of rules) {
for (const path of expandCEL(rule.cel_expression ?? "")) {
allPaths.push({ rule, path: path.map(normalizeCond) });
}
}
// Count how many paths each condition appears in.
// Conditions shared by more paths sort earlier → maximum prefix sharing.
const freq = new Map<string, number>();
for (const { path } of allPaths) {
for (const cond of new Set(path)) {
freq.set(cond, (freq.get(cond) ?? 0) + 1);
}
}
// Insert into trie with paths sorted by frequency desc, then alphabetically.
for (const { rule, path } of allPaths) {
const sorted = [...path].sort((a, b) => {
const d = (freq.get(b) ?? 0) - (freq.get(a) ?? 0);
return d !== 0 ? d : a.localeCompare(b);
});
let node = root;
for (const cond of sorted) {
if (!node.children.has(cond)) node.children.set(cond, mkNode(cond));
node = node.children.get(cond)!;
}
if (!node.terminals.find((r) => r.id === rule.id)) node.terminals.push(rule);
}
return root;
}
/** Merge structurally identical subtrees so OR-expanded duplicates share one node. */
export function mergeSubtrees(root: TrieNode): void {
const registry = new Map<string, TrieNode>();
const nodeCanon = new Map<string, string>();
function canon(node: TrieNode, seen = new Set<string>()): string {
if (nodeCanon.has(node.id)) return nodeCanon.get(node.id)!;
if (seen.has(node.id)) return node.id;
seen.add(node.id);
const termKey = node.terminals
.map((r) => r.id)
.sort()
.join(",");
const childKey = Array.from(node.children.entries())
.map(([c, ch]) => `${c}:${canon(ch, new Set(seen))}`)
.sort()
.join("|");
const key = `${node.condition}::${termKey}::${childKey}`;
nodeCanon.set(node.id, key);
if (!registry.has(key)) registry.set(key, node);
return key;
}
function postOrder(node: TrieNode, seen = new Set<string>()): void {
if (seen.has(node.id)) return;
seen.add(node.id);
for (const ch of node.children.values()) postOrder(ch, seen);
canon(node);
}
postOrder(root);
function replace(node: TrieNode, seen = new Set<string>()): void {
if (seen.has(node.id)) return;
seen.add(node.id);
for (const [cond, ch] of Array.from(node.children.entries())) {
const canonical = registry.get(nodeCanon.get(ch.id)!)!;
if (canonical.id !== ch.id) node.children.set(cond, canonical);
replace(canonical, seen);
}
}
replace(root);
}
// ─── Scope colour helpers ──────────────────────────────────────────────────
function collectTerminals(node: TrieNode, seen = new Set<string>()): RoutingRule[] {
if (seen.has(node.id)) return [];
seen.add(node.id);
const acc = [...node.terminals];
for (const ch of node.children.values()) acc.push(...collectTerminals(ch, seen));
return acc;
}
function nodeColor(node: TrieNode, cache?: Map<string, string | null>): string | null {
if (cache?.has(node.id)) return cache.get(node.id)!;
const rules = collectTerminals(node);
if (!rules.length) {
cache?.set(node.id, null);
return null;
}
// Deduplicate by rule.id before counting — collectTerminals returns one entry
// per OR-expanded path, so a multi-branch rule would otherwise be over-counted.
const uniqueRules = [...new Map(rules.map((r) => [r.id, r])).values()];
// Count rules per scope to produce a weighted blend.
const counts = new Map<string, number>();
for (const r of uniqueRules) counts.set(r.scope, (counts.get(r.scope) ?? 0) + 1);
const entries = [...counts.entries()]
.map(([scope, count]): { color: string | undefined; count: number } => ({
color: SCOPE_CONFIG[scope as ScopeKey]?.color,
count,
}))
.filter((e): e is { color: string; count: number } => !!e.color);
const result = entries.length
? blendColors(
entries.map((e) => e.color),
entries.map((e) => e.count),
)
: null;
cache?.set(node.id, result);
return result;
}
// ─── DAG structure collection ─────────────────────────────────────────────
function collectDAGStructure(root: TrieNode): { lNodes: LNode[]; lEdges: LEdge[] } {
const colorCache = new Map<string, string | null>();
const lNodes: LNode[] = [{ id: "source", kind: "source", data: {}, w: SRC_W, h: SRC_H }];
const lEdges: LEdge[] = [];
const addedNodes = new Set<string>(["source"]);
const addedEdges = new Set<string>();
const processed = new Set<string>();
const chainQueue: { ruleId: string; rule: RoutingRule; sc: string }[] = [];
function addEdge(src: string, tgt: string, label?: string, color?: string, opts?: Partial<LEdge>) {
const key = `${src}${tgt}${opts?.isChainBack ? ":chain" : ""}`;
if (addedEdges.has(key)) return;
addedEdges.add(key);
lEdges.push({ source: src, target: tgt, label, color, ...opts });
}
function traverse(node: TrieNode, parentId: string) {
const isRoot = node.condition === null;
const selfId = isRoot ? "source" : node.id;
if (!isRoot) {
if (!addedNodes.has(selfId)) {
const color = nodeColor(node, colorCache);
const terminalRules = collectTerminals(node);
const scopes = [...new Set(terminalRules.map((r) => r.scope))];
lNodes.push({ id: selfId, kind: "condition", data: { condition: node.condition, color, scopes }, w: COND_W, h: COND_H });
addedNodes.add(selfId);
}
addEdge(parentId, selfId, undefined, nodeColor(node, colorCache) ?? undefined);
}
// Don't re-traverse a shared node's subtree from a second parent
if (!isRoot && processed.has(selfId)) return;
processed.add(selfId);
for (const ch of node.children.values()) traverse(ch, selfId);
for (const rule of node.terminals) {
const ruleId = `rule-${rule.id}`;
const sc = SCOPE_CONFIG[rule.scope as ScopeKey]?.color ?? "#9ca3af";
if (!addedNodes.has(ruleId)) {
lNodes.push({ id: ruleId, kind: "rule", data: { rule, scopeColor: sc }, w: RULE_W, h: RULE_H });
addedNodes.add(ruleId);
}
addEdge(selfId, ruleId, undefined, sc);
if (rule.chain_rule) chainQueue.push({ ruleId, rule, sc });
}
}
traverse(root, "");
// ── Second pass: chain edges to specific matching condition nodes ──────
// For each chain rule, evaluate its resolved targets against every
// condition node reachable from source. Connect to the first satisfied
// condition in each path so the edge shows exactly where the chain lands.
if (chainQueue.length > 0) {
// Build an adjacency list (forward edges only, by definition at this point)
const childrenOf = new Map<string, string[]>();
for (const e of lEdges) {
if (!childrenOf.has(e.source)) childrenOf.set(e.source, []);
childrenOf.get(e.source)!.push(e.target);
}
const nodeById = new Map(lNodes.map((n) => [n.id, n]));
/** Walk forward from `startIds`, following only condition nodes, and
* return the deepest node in each branch whose condition evaluates to
* true for `vars`. Only emits a node if none of its condition children
* also evaluate to true (deepest satisfied entry point semantics).
*
* Each result carries a `strong` flag: true when every condition on the
* path evaluated to `true` (static chain), false when any condition was
* `null` / too complex to evaluate (dynamic chain). */
function findEntries(startIds: string[], vars: Record<string, string>): Array<{ id: string; strong: boolean }> {
const results: Array<{ id: string; strong: boolean }> = [];
const visited = new Set<string>();
/** Returns true if this node (or any descendant condition) matched.
* `strong` is false once we have passed through any `null` hop. */
function explore(id: string, strong: boolean): boolean {
if (visited.has(id)) return false;
visited.add(id);
const node = nodeById.get(id);
if (!node || node.kind !== "condition") return false;
const result = evalChainCondition(node.data.condition as string, vars);
if (result === false) return false; // branch blocked
if (result === true) {
// Continue into children — prefer the deepest static match
let hasDeeper = false;
for (const childId of childrenOf.get(id) ?? []) {
if (explore(childId, strong)) hasDeeper = true;
}
if (!hasDeeper) results.push({ id, strong });
return true;
}
// result === null (too complex) — explore children but mark as weak
let anyMatch = false;
for (const childId of childrenOf.get(id) ?? []) {
if (explore(childId, false)) anyMatch = true;
}
return anyMatch;
}
for (const id of startIds) explore(id, true);
return results;
}
for (const { ruleId, rule, sc } of chainQueue) {
// Collect unique (provider, model) pairs across all targets
const seen = new Set<string>();
for (const t of rule.targets) {
const vars: Record<string, string> = {};
if (t.provider) vars.provider = t.provider;
if (t.model) vars.model = t.model;
if (!Object.keys(vars).length) {
// passthrough target — chain loops back to source (static: we know the input is unchanged)
addEdge(ruleId, "source", "↺", sc, { isChainBack: true, isChainWeak: false, sourceHandle: "chain-out" });
continue;
}
const key = JSON.stringify(vars);
if (seen.has(key)) continue;
seen.add(key);
const entries = findEntries(childrenOf.get("source") ?? [], vars);
if (entries.length === 0) {
// resolved vars match no condition node — fall back to source
addEdge(ruleId, "source", "↺", sc, { isChainBack: true, isChainWeak: false, sourceHandle: "chain-out" });
}
for (const { id: condId, strong } of entries) {
addEdge(ruleId, condId, "↺", sc, { isChainBack: true, isChainWeak: !strong, sourceHandle: "chain-out" });
}
}
}
}
return { lNodes, lEdges };
}
// ─── Dagre layered layout (LR) ───────────────────────────────────────────
//
// Uses @dagrejs/dagre with the network-simplex ranker for crossing reduction and
// consistent rank spacing. Chain-back edges are excluded — they are drawn after.
function computeLRLayout(lNodes: LNode[], lEdges: LEdge[]): Map<string, { x: number; y: number }> {
const g = new dagre.graphlib.Graph({ multigraph: false });
g.setGraph({
rankdir: "LR",
// Network-simplex tends to produce cleaner orderings than longest-path on DAGs.
ranker: "network-simplex",
ranksep: DAGRE_RANKSEP,
nodesep: DAGRE_NODESEP,
edgesep: 16,
marginx: DAGRE_MARGIN,
marginy: DAGRE_MARGIN,
align: "UL",
});
g.setDefaultEdgeLabel(() => ({}));
for (const n of lNodes) {
g.setNode(n.id, { width: n.w, height: n.h });
}
const forward = lEdges.filter((e) => !e.isChainBack);
const edgeKey = new Set<string>();
for (const e of forward) {
const k = `${e.source}\0${e.target}`;
if (edgeKey.has(k)) continue;
edgeKey.add(k);
if (g.hasNode(e.source) && g.hasNode(e.target)) {
g.setEdge(e.source, e.target);
}
}
dagre.layout(g);
const positions = new Map<string, { x: number; y: number }>();
for (const n of lNodes) {
const laid = g.node(n.id);
if (laid === undefined) continue;
// Dagre uses x,y as the centre of each node.
positions.set(n.id, {
x: laid.x - n.w / 2,
y: laid.y - n.h / 2,
});
}
// Dagre pins the first rank to the top of the layout; shift "source" so its
// vertical centre matches the midpoint of every *other* node's bounding box.
centerSourceVertically(positions, lNodes);
// Pull the source node an extra gap to the left so it visually breathes
// away from the first condition column.
const sourcePos = positions.get("source");
if (sourcePos) sourcePos.x -= 200;
return positions;
}
/** Move the source node so it sits at the vertical centre of the rest of the graph. */
function centerSourceVertically(positions: Map<string, { x: number; y: number }>, lNodes: LNode[]): void {
const sourceEntry = lNodes.find((n) => n.id === "source");
const sourcePos = positions.get("source");
if (!sourceEntry || !sourcePos) return;
const others = lNodes.filter((n) => n.id !== "source");
if (others.length === 0) return;
let minTop = Infinity;
let maxBottom = -Infinity;
for (const n of others) {
const p = positions.get(n.id);
if (!p) continue;
minTop = Math.min(minTop, p.y);
maxBottom = Math.max(maxBottom, p.y + n.h);
}
if (!Number.isFinite(minTop) || !Number.isFinite(maxBottom)) return;
const midY = (minTop + maxBottom) / 2;
sourcePos.y = midY - sourceEntry.h / 2;
}
// ─── Build React Flow graph ────────────────────────────────────────────────
export function buildGraph(rules: RoutingRule[]): { nodes: Node[]; edges: Edge[] } {
const trie = buildTrie(rules);
mergeSubtrees(trie);
const { lNodes, lEdges } = collectDAGStructure(trie);
// Chain-back edges form cycles — exclude them from layout (forward edges only).
const positions = computeLRLayout(
lNodes,
lEdges.filter((e) => !e.isChainBack),
);
const kindType: Record<string, string> = {
source: "rfSource",
condition: "rfCondition",
rule: "rfRule",
};
const rfNodes: Node[] = lNodes.map((ln) => ({
id: ln.id,
type: kindType[ln.kind],
position: positions.get(ln.id) ?? { x: 0, y: 0 },
data: ln.data,
draggable: true,
selectable: true,
connectable: false,
}));
const rfEdges: Edge[] = lEdges.map((le) => {
const base = {
id: `e-${le.source}-${le.target}${le.isChainBack ? "-chain" : ""}`,
source: le.source,
target: le.target,
...(le.sourceHandle ? { sourceHandle: le.sourceHandle } : {}),
...(le.targetHandle ? { targetHandle: le.targetHandle } : {}),
};
if (le.isChainBack) {
// Both dashed: longer dashes (static) vs shorter dashes (dynamic). Mid-arrow in rfChainEdge.
const weak = le.isChainWeak;
return {
...base,
type: "rfChain",
data: { chainWeak: weak },
animated: false,
...(weak ? { className: "rf-chain-edge-dynamic" } : {}),
style: {
stroke: le.color,
strokeWidth: 1.5,
strokeLinecap: "round",
...(weak ? {} : { strokeDasharray: "14 10" }),
},
};
}
return {
...base,
type: "simplebezier",
style: { stroke: le.color ?? "var(--border)", strokeWidth: le.color ? 1.5 : 1 },
};
});
return { nodes: rfNodes, edges: rfEdges };
}