Browse Source

feat(web-v3): 游走画布改按边列表(10 条边×规则包×门控矩阵×执行统计)

- WalkEdgeList:四源前端合并(walk-strategy 目录+绑定/walk-policy 门控预算/walk_actions 执行/dashboard 兜底)
- 三行布局:边本身/规则包绑定/判定门控矩阵+预算;执行徽章按本次 run 聚合
- 删 WalkGraphFlow.tsx + dagre 依赖;新增 walk-edge-* 样式
- 实测 v1_run_040e6a165f6a:10 条目、decision_to_asset advisory 绑定、author_to_works 三态门控、执行计数和=walk_actions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sam Lee 1 ngày trước cách đây
mục cha
commit
548b646666

+ 102 - 8
web/app/globals.css

@@ -1408,16 +1408,110 @@ a {
 }
 
 /* 游走路径图:React Flow 画布容器(需明确高度) */
-.walk-flow-shell {
-  height: 560px;
+/* 游走「按边列表」 */
+.walk-edge-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+.walk-edge-hint {
+  font-size: 12px;
+  color: #6b7689;
+  margin-bottom: 2px;
+}
+.walk-edge-card {
+  display: flex;
+  flex-direction: column;
+  gap: 7px;
+  width: 100%;
+  text-align: left;
+  padding: 12px 14px;
   border: 1px solid #e4e8f0;
   border-radius: 12px;
-  overflow: hidden;
-  background: #fafbfd;
-}
-.walk-flow-shell .react-flow__node {
+  background: #ffffff;
   cursor: pointer;
 }
-.walk-flow-shell .react-flow__attribution {
-  display: none;
+.walk-edge-card:hover {
+  border-color: #b9c4d6;
+  background: #fcfdff;
+}
+.walk-edge-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+  color: #3a4658;
+}
+.walk-edge-row-head strong {
+  font-size: 14px;
+  color: #172033;
+}
+.walk-edge-row code {
+  font-size: 11px;
+  color: #6b7689;
+  background: #f1f4f9;
+  padding: 1px 6px;
+  border-radius: 5px;
+}
+.walk-edge-flow {
+  font-size: 12px;
+  color: #5b6678;
+}
+.walk-edge-rowlabel {
+  min-width: 64px;
+  font-size: 12px;
+  color: #9aa4b5;
+}
+.walk-edge-tag {
+  font-size: 11px;
+  color: #4a5568;
+  background: #eef0f4;
+  border-radius: 5px;
+  padding: 1px 7px;
+}
+.walk-edge-exec {
+  margin-left: auto;
+  font-size: 12px;
+  font-weight: 600;
+  padding: 2px 9px;
+  border-radius: 999px;
+}
+.walk-edge-exec.ok {
+  color: #1d6b3e;
+  background: #e9f7ef;
+}
+.walk-edge-exec.warn {
+  color: #9a6a0a;
+  background: #fff6e5;
+}
+.walk-edge-exec.muted {
+  color: #5b6678;
+  background: #eef0f4;
+}
+.walk-edge-gate-chip {
+  font-size: 11px;
+  font-weight: 600;
+  padding: 2px 8px;
+  border-radius: 6px;
+}
+.walk-edge-gate-chip.ok {
+  color: #1d6b3e;
+  background: #e9f7ef;
+}
+.walk-edge-gate-chip.warn {
+  color: #9a6a0a;
+  background: #fff6e5;
+}
+.walk-edge-gate-chip.deny {
+  color: #a82626;
+  background: #fdecec;
+}
+.walk-edge-budget {
+  margin-left: auto;
+  font-size: 12px;
+  color: #6b7689;
+}
+.walk-edge-row .muted {
+  color: #9aa4b5;
 }

+ 6 - 14
web/features/runs/RunDashboardPage.tsx

@@ -1,7 +1,7 @@
 "use client";
 
 import Link from "next/link";
-import { WalkGraphFlow } from "@/features/runs/WalkGraphFlow";
+import { WalkEdgeList } from "@/features/runs/WalkEdgeList";
 
 import {
   ChevronDown,
@@ -574,14 +574,15 @@ function StagePanel({
   }
   if (activeStage === "walk") {
     return (
-      <BusinessSection title="游走路径图" icon={<GitBranch size={17} />}>
+      <BusinessSection title="游走边 / 规则包" icon={<GitBranch size={17} />}>
         <ConclusionBody stage={data.dashboard.stage_conclusions.find((stage) => stage.stage_id === "walk")} />
-        <WalkGraphFlow
-          graph={data.dashboard.walk_graph}
+        <WalkEdgeList
+          runId={data.dashboard.run_id}
+          fallbackEdges={data.dashboard.walk_graph.edges}
           onOpen={(payload) =>
             onOpenDrawer({
               kind: "walk",
-              title: String(payload.label || payload.id || "游走详情"),
+              title: String(payload.labelCn || payload.edgeId || "游走边详情"),
               payload
             })
           }
@@ -840,15 +841,6 @@ function reasonLabel(reason: unknown): string {
 }
 
 
-function edgeExecutionSuffix(payload: Record<string, unknown>): string {
-  if (!payload.rule_pack) return "";
-  if (payload.rule_pack_executed === false) return " · 已归属未运行";
-  if (payload.executed_rule_pack_id && payload.executed_rule_pack_id !== payload.rule_pack) {
-    return " · 执行:内容包";
-  }
-  return "";
-}
-
 function TechnicalDetailsDrawer({
   drawer,
   onClose

+ 319 - 0
web/features/runs/WalkEdgeList.tsx

@@ -0,0 +1,319 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { GitBranch } from "lucide-react";
+import { getConfigWalkPolicy, getConfigWalkStrategy, getRuntimeFile } from "@/lib/api/client";
+import type { WalkGraphEdge } from "@/lib/api/types";
+
+// 边的中文名(配置里 edge_label 是英文;此处给业务可读名)。
+const EDGE_LABEL_CN: Record<string, string> = {
+  query_next_page: "翻页搜索",
+  search_page_to_content: "搜索页拆视频",
+  video_to_author: "视频带出作者",
+  author_to_works: "作者拉作品页",
+  author_work_to_content: "作者作品回到判断",
+  video_to_hashtag: "视频拆话题",
+  hashtag_to_query: "话题生成新 query",
+  decision_to_asset: "判定落资产",
+  path_stop: "路径停止",
+  budget_downgrade: "预算降级"
+};
+
+const GATE_ACTIONS: Array<{ key: string; cn: string }> = [
+  { key: "ADD_TO_CONTENT_POOL", cn: "入池" },
+  { key: "KEEP_CONTENT_FOR_REVIEW", cn: "复看" },
+  { key: "REJECT_CONTENT", cn: "淘汰" }
+];
+
+const RULE_PACK_LABEL: Record<string, string> = {
+  douyin_content_discovery_rule_pack_v1: "内容发现规则包 V1"
+};
+
+type AnyRec = Record<string, unknown>;
+
+type PermVerdict = "allow" | "allow_low_budget" | "deny" | "n/a";
+
+type WalkEdgeEntry = {
+  edgeId: string;
+  labelCn: string;
+  edgeType: string;
+  fromNode: string;
+  toNode: string;
+  canLoop: boolean;
+  runtimeStage: string;
+  notes: string;
+  binding: { rulePackId: string; dispatchPolicy: string; required: boolean } | null;
+  permissions: Record<string, PermVerdict> | null; // action -> verdict;null=该边无判定门控
+  permissionSource: "explicit" | "follows_video_to_hashtag" | null;
+  budget: AnyRec | null;
+  execution: {
+    actionCount: number;
+    statusCounts: Record<string, number>;
+    reasonCodes: string[];
+    rulePackIds: string[];
+    actions: AnyRec[];
+  };
+};
+
+// 解包 walk_policy 的 {value,provenance,tbd} 留痕形态。
+function unwrap(v: unknown): string {
+  if (v && typeof v === "object" && "value" in (v as AnyRec)) {
+    return String((v as AnyRec).value);
+  }
+  return String(v);
+}
+
+function buildEntries(
+  catalog: AnyRec[],
+  binding: AnyRec[],
+  policy: AnyRec | null,
+  actions: AnyRec[]
+): WalkEdgeEntry[] {
+  const bindingByEdge = new Map<string, AnyRec>();
+  binding.forEach((b) => bindingByEdge.set(String(b.edge_id), b));
+
+  const permTable = (policy?.edge_permissions as AnyRec) || {};
+  const budgetByEdge = new Map<string, AnyRec>();
+  ((policy?.edge_budgets as AnyRec[]) || []).forEach((b) => budgetByEdge.set(String(b.edge_id), b));
+
+  // 哪些边在 edge_permissions 里被显式控制
+  const controlledEdges = new Set<string>();
+  GATE_ACTIONS.forEach(({ key }) => {
+    const row = permTable[key];
+    if (row && typeof row === "object") {
+      Object.keys(row as AnyRec).forEach((edgeId) => {
+        if (!edgeId.startsWith("_") && edgeId !== "note") controlledEdges.add(edgeId);
+      });
+    }
+  });
+
+  // 执行聚合:按 edge_id 分组
+  const actionsByEdge = new Map<string, AnyRec[]>();
+  actions.forEach((a) => {
+    const eid = String(a.edge_id || "");
+    if (!eid) return;
+    if (!actionsByEdge.has(eid)) actionsByEdge.set(eid, []);
+    actionsByEdge.get(eid)!.push(a);
+  });
+
+  return catalog.map((e) => {
+    const edgeId = String(e.edge_id);
+    const bind = bindingByEdge.get(edgeId);
+    const acts = actionsByEdge.get(edgeId) || [];
+
+    let permissions: Record<string, PermVerdict> | null = null;
+    let permissionSource: WalkEdgeEntry["permissionSource"] = null;
+    if (controlledEdges.has(edgeId)) {
+      permissions = {};
+      GATE_ACTIONS.forEach(({ key }) => {
+        const row = (permTable[key] as AnyRec) || {};
+        permissions![key] = (unwrap(row[edgeId]) || "deny") as PermVerdict;
+      });
+      permissionSource = "explicit";
+    } else if (edgeId === "hashtag_to_query") {
+      permissionSource = "follows_video_to_hashtag";
+    }
+
+    const statusCounts: Record<string, number> = {};
+    const reasonCodes = new Set<string>();
+    const rulePackIds = new Set<string>();
+    acts.forEach((a) => {
+      const st = String(a.walk_status || "unknown");
+      statusCounts[st] = (statusCounts[st] || 0) + 1;
+      if (a.reason_code) reasonCodes.add(String(a.reason_code));
+      if (a.rule_pack_id) rulePackIds.add(String(a.rule_pack_id));
+    });
+
+    return {
+      edgeId,
+      labelCn: EDGE_LABEL_CN[edgeId] || String(e.edge_label || edgeId),
+      edgeType: String(e.edge_type || ""),
+      fromNode: String(e.from_node_type || ""),
+      toNode: String(e.to_node_type || ""),
+      canLoop: Boolean(e.can_loop),
+      runtimeStage: String(e.runtime_stage || ""),
+      notes: String(e.notes || ""),
+      binding: bind
+        ? {
+            rulePackId: String(bind.rule_pack_id),
+            dispatchPolicy: String(bind.dispatch_policy || ""),
+            required: Boolean(bind.required)
+          }
+        : null,
+      permissions,
+      permissionSource,
+      budget: budgetByEdge.get(edgeId) || null,
+      execution: {
+        actionCount: acts.length,
+        statusCounts,
+        reasonCodes: [...reasonCodes],
+        rulePackIds: [...rulePackIds],
+        actions: acts
+      }
+    };
+  });
+}
+
+function execBadge(e: WalkEdgeEntry): { text: string; cls: string } {
+  const { actionCount, statusCounts, reasonCodes } = e.execution;
+  if (actionCount === 0) return { text: "本次未触发", cls: "muted" };
+  const blocked = (statusCounts.rule_blocked || 0) + (statusCounts.failed || 0);
+  if (blocked > 0 || reasonCodes.length) {
+    return { text: `执行 ${actionCount} 次 · 含拦截/降级`, cls: "warn" };
+  }
+  return { text: `执行 ${actionCount} 次`, cls: "ok" };
+}
+
+function budgetText(b: AnyRec): string {
+  const parts: string[] = [];
+  const fields: Array<[string, string]> = [
+    ["max_total_actions", "单 run"],
+    ["max_per_query", "每 query"],
+    ["max_works_per_author", "每作者作品"],
+    ["max_per_content", "每内容"],
+    ["max_pages", "页数"],
+    ["max_tag_hops", "tag 跳数"]
+  ];
+  fields.forEach(([k, cn]) => {
+    if (b[k] !== undefined && b[k] !== null) parts.push(`${cn} ≤ ${unwrap(b[k])}`);
+  });
+  return parts.join(" · ") || "无显式预算";
+}
+
+export function WalkEdgeList({
+  runId,
+  fallbackEdges,
+  onOpen
+}: {
+  runId: string;
+  fallbackEdges: WalkGraphEdge[];
+  onOpen: (payload: Record<string, unknown>) => void;
+}) {
+  const [catalog, setCatalog] = useState<AnyRec[] | null>(null);
+  const [binding, setBinding] = useState<AnyRec[]>([]);
+  const [policy, setPolicy] = useState<AnyRec | null>(null);
+  const [actions, setActions] = useState<AnyRec[] | null>(null);
+  const [configError, setConfigError] = useState(false);
+  const [policyError, setPolicyError] = useState(false);
+
+  useEffect(() => {
+    let alive = true;
+    getConfigWalkStrategy()
+      .then((r) => {
+        if (!alive) return;
+        setCatalog((r.data.walk_edge_catalog as AnyRec[]) || []);
+        setBinding((r.data.walk_rule_pack_binding as AnyRec[]) || []);
+      })
+      .catch(() => alive && setConfigError(true));
+    getConfigWalkPolicy()
+      .then((r) => alive && setPolicy(r.data))
+      .catch(() => alive && setPolicyError(true));
+    getRuntimeFile(runId, "walk_actions.jsonl", 500)
+      .then((r) => alive && setActions(r.records || []))
+      .catch(() => {
+        // 兜底:walk_actions 缺失时,从 dashboard 合成边按 edge_id(=label)聚合。
+        if (!alive) return;
+        const synthetic = fallbackEdges
+          .filter((e) => e.label && EDGE_LABEL_CN[String(e.label)])
+          .map((e) => ({
+            edge_id: e.label,
+            walk_status: e.status,
+            reason_code: e.reason_code,
+            rule_pack_id: e.rule_pack
+          }));
+        setActions(synthetic);
+      });
+    return () => {
+      alive = false;
+    };
+  }, [runId, fallbackEdges]);
+
+  const entries = useMemo(() => {
+    if (!catalog) return null;
+    return buildEntries(catalog, binding, policy, actions || []);
+  }, [catalog, binding, policy, actions]);
+
+  if (configError) {
+    return (
+      <div className="walk-empty-board">
+        <GitBranch size={28} />
+        <strong>游走边目录加载失败</strong>
+        <span>无法读取 /config/walk-strategy;请确认后端配置端点可用。</span>
+      </div>
+    );
+  }
+  if (!entries) {
+    return <div className="empty-state">正在加载游走边目录…</div>;
+  }
+
+  return (
+    <div className="walk-edge-list">
+      <div className="walk-edge-hint">
+        全部 {entries.length} 条游走边目录;每条边的规则包绑定与判定门控来自配置,执行情况来自本次 run。
+        {policyError ? "(门控配置 /config/walk-policy 不可用,门控行降级显示)" : ""}
+      </div>
+      {entries.map((e) => {
+        const badge = execBadge(e);
+        return (
+          <button type="button" className="walk-edge-card" key={e.edgeId} onClick={() => onOpen(e as unknown as Record<string, unknown>)}>
+            {/* 行1:边本身 */}
+            <div className="walk-edge-row walk-edge-row-head">
+              <strong>{e.labelCn}</strong>
+              <code>{e.edgeId}</code>
+              <span className="walk-edge-flow">
+                {e.fromNode} → {e.toNode}
+              </span>
+              {e.canLoop ? <span className="walk-edge-tag">可循环</span> : null}
+              <span className={`walk-edge-exec ${badge.cls}`}>{badge.text}</span>
+            </div>
+
+            {/* 行2:规则包 */}
+            <div className="walk-edge-row">
+              <span className="walk-edge-rowlabel">规则包</span>
+              {e.binding ? (
+                <span>
+                  {RULE_PACK_LABEL[e.binding.rulePackId] || e.binding.rulePackId} · {e.binding.dispatchPolicy} 绑定
+                  {e.binding.required ? "(必须)" : ""}
+                </span>
+              ) : (
+                <span className="muted">无规则包(导航边 / 系统边)</span>
+              )}
+              {e.execution.rulePackIds
+                .filter((id) => id !== e.binding?.rulePackId)
+                .map((id) => (
+                  <span className="walk-edge-tag" key={id} title="本次 run 实际执行包">
+                    执行包:{RULE_PACK_LABEL[id] || id}
+                  </span>
+                ))}
+            </div>
+
+            {/* 行3:门控矩阵 + 预算 */}
+            <div className="walk-edge-row">
+              <span className="walk-edge-rowlabel">判定门控</span>
+              {e.permissions ? (
+                GATE_ACTIONS.map(({ key, cn }) => {
+                  const v = e.permissions![key];
+                  const cls =
+                    v === "allow" ? "ok" : v === "allow_low_budget" ? "warn" : "deny";
+                  const vCn = v === "allow" ? "放行" : v === "allow_low_budget" ? "低预算" : "拦截";
+                  return (
+                    <span className={`walk-edge-gate-chip ${cls}`} key={key}>
+                      {cn} → {vCn}
+                    </span>
+                  );
+                })
+              ) : e.permissionSource === "follows_video_to_hashtag" ? (
+                <span className="muted">随 video_to_hashtag 链放行</span>
+              ) : (
+                <span className="muted">无判定门控</span>
+              )}
+              <span className="walk-edge-budget">
+                预算:{e.budget ? budgetText(e.budget) : "无显式预算"}
+              </span>
+            </div>
+          </button>
+        );
+      })}
+    </div>
+  );
+}

+ 0 - 180
web/features/runs/WalkGraphFlow.tsx

@@ -1,180 +0,0 @@
-"use client";
-
-import { useMemo } from "react";
-import dagre from "dagre";
-import {
-  Background,
-  Controls,
-  Handle,
-  MiniMap,
-  Position,
-  ReactFlow,
-  type Edge,
-  type Node,
-  type NodeProps
-} from "@xyflow/react";
-import "@xyflow/react/dist/style.css";
-import { GitBranch } from "lucide-react";
-import type { WalkGraph } from "@/lib/api/types";
-
-const NODE_W = 200;
-const NODE_H = 62;
-
-function statusColor(status?: string | null): { bg: string; border: string; text: string } {
-  switch (status) {
-    case "success":
-    case "matched":
-      return { bg: "#e9f7ef", border: "#2e9b5b", text: "#1d6b3e" };
-    case "rejected":
-    case "failed":
-    case "rule_blocked":
-      return { bg: "#fdecec", border: "#dc4040", text: "#a82626" };
-    case "pending":
-      return { bg: "#fff6e5", border: "#e0a020", text: "#9a6a0a" };
-    case "skipped":
-      return { bg: "#eef0f4", border: "#9aa4b5", text: "#5b6678" };
-    default:
-      return { bg: "#ffffff", border: "#cdd5e3", text: "#3a4658" };
-  }
-}
-
-type WalkNodeData = {
-  label: string;
-  type: string;
-  status: string;
-  payload: Record<string, unknown>;
-};
-
-function WalkNode({ data }: NodeProps) {
-  const d = data as unknown as WalkNodeData;
-  const c = statusColor(d.status);
-  return (
-    <div
-      style={{
-        width: NODE_W,
-        minHeight: NODE_H,
-        padding: "8px 12px",
-        borderRadius: 12,
-        border: `1px solid ${c.border}`,
-        background: c.bg,
-        boxShadow: "0 1px 3px rgba(20,30,55,0.08)",
-        display: "flex",
-        flexDirection: "column",
-        gap: 3
-      }}
-    >
-      <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
-      <strong
-        style={{
-          fontSize: 13,
-          color: "#172033",
-          overflow: "hidden",
-          textOverflow: "ellipsis",
-          whiteSpace: "nowrap"
-        }}
-      >
-        {d.label}
-      </strong>
-      <span style={{ fontSize: 11, color: c.text, fontWeight: 600 }}>
-        {d.type} · {d.status}
-      </span>
-      <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
-    </div>
-  );
-}
-
-const nodeTypes = { walk: WalkNode };
-
-function buildFlow(graph: WalkGraph): { nodes: Node[]; edges: Edge[] } {
-  const g = new dagre.graphlib.Graph();
-  g.setGraph({ rankdir: "LR", nodesep: 48, ranksep: 110, marginx: 24, marginy: 24 });
-  g.setDefaultEdgeLabel(() => ({}));
-
-  const nodeIds = new Set(graph.nodes.map((n) => n.id));
-  graph.nodes.forEach((n) => g.setNode(n.id, { width: NODE_W, height: NODE_H }));
-  const validEdges = graph.edges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target));
-  validEdges.forEach((e) => g.setEdge(e.source, e.target));
-  dagre.layout(g);
-
-  const nodes: Node[] = graph.nodes.map((n) => {
-    const p = g.node(n.id);
-    return {
-      id: n.id,
-      type: "walk",
-      position: { x: (p?.x ?? 0) - NODE_W / 2, y: (p?.y ?? 0) - NODE_H / 2 },
-      data: {
-        label: n.label,
-        type: n.type,
-        status: n.status,
-        payload: n as unknown as Record<string, unknown>
-      }
-    };
-  });
-
-  const edges: Edge[] = validEdges.map((e, i) => {
-    const c = statusColor(e.status);
-    const exec =
-      e.rule_pack_executed === false
-        ? " · 已归属未运行"
-        : e.executed_rule_pack_id && e.executed_rule_pack_id !== e.rule_pack
-          ? " · 执行:内容包"
-          : "";
-    return {
-      id: e.id || `e${i}`,
-      source: e.source,
-      target: e.target,
-      label: `${e.label || e.status || ""}${exec}`,
-      data: { payload: e as unknown as Record<string, unknown> },
-      style: { stroke: c.border, strokeWidth: 1.5 },
-      labelStyle: { fontSize: 11, fill: "#5b6678", fontWeight: 600 },
-      labelBgStyle: { fill: "#ffffff", fillOpacity: 0.9 },
-      labelBgPadding: [4, 2] as [number, number],
-      labelBgBorderRadius: 4
-    };
-  });
-
-  return { nodes, edges };
-}
-
-export function WalkGraphFlow({
-  graph,
-  onOpen
-}: {
-  graph: WalkGraph;
-  onOpen: (payload: Record<string, unknown>) => void;
-}) {
-  const { nodes, edges } = useMemo(() => buildFlow(graph), [graph]);
-
-  if (!nodes.length || (nodes.length === 1 && !edges.length)) {
-    return (
-      <div className="walk-empty-board">
-        <GitBranch size={28} />
-        <strong>当前 run 没有可执行游走路径</strong>
-        <span>如果 query 或平台阶段失败,游走图会保持为空;这不是伪造缺口。</span>
-      </div>
-    );
-  }
-
-  return (
-    <div className="walk-flow-shell">
-      <ReactFlow
-        nodes={nodes}
-        edges={edges}
-        nodeTypes={nodeTypes}
-        fitView
-        minZoom={0.2}
-        maxZoom={2}
-        nodesConnectable={false}
-        onNodeClick={(_, node) => onOpen((node.data as unknown as WalkNodeData).payload)}
-        onEdgeClick={(_, edge) =>
-          onOpen(((edge.data as { payload?: Record<string, unknown> })?.payload) || {})
-        }
-        proOptions={{ hideAttribution: true }}
-      >
-        <Background gap={18} color="#e4e8f0" />
-        <Controls showInteractive={false} />
-        <MiniMap pannable zoomable nodeStrokeWidth={2} />
-      </ReactFlow>
-    </div>
-  );
-}

+ 3 - 277
web/package-lock.json

@@ -8,15 +8,12 @@
       "name": "content-find-agent-web",
       "version": "0.1.0",
       "dependencies": {
-        "@xyflow/react": "^12.11.0",
-        "dagre": "^0.8.5",
         "lucide-react": "^0.468.0",
         "next": "^15.1.0",
         "react": "^19.0.0",
         "react-dom": "^19.0.0"
       },
       "devDependencies": {
-        "@types/dagre": "^0.7.54",
         "@types/node": "^22.10.0",
         "@types/react": "^19.0.0",
         "@types/react-dom": "^19.0.0",
@@ -702,62 +699,6 @@
         "tslib": "^2.8.0"
       }
     },
-    "node_modules/@types/d3-color": {
-      "version": "3.1.3",
-      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
-      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-drag": {
-      "version": "3.0.7",
-      "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
-      "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-selection": "*"
-      }
-    },
-    "node_modules/@types/d3-interpolate": {
-      "version": "3.0.4",
-      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
-      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-color": "*"
-      }
-    },
-    "node_modules/@types/d3-selection": {
-      "version": "3.0.11",
-      "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
-      "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
-      "license": "MIT"
-    },
-    "node_modules/@types/d3-transition": {
-      "version": "3.0.9",
-      "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
-      "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-selection": "*"
-      }
-    },
-    "node_modules/@types/d3-zoom": {
-      "version": "3.0.8",
-      "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
-      "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-interpolate": "*",
-        "@types/d3-selection": "*"
-      }
-    },
-    "node_modules/@types/dagre": {
-      "version": "0.7.54",
-      "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz",
-      "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/@types/node": {
       "version": "22.19.20",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz",
@@ -772,7 +713,7 @@
       "version": "19.2.17",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
       "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "csstype": "^3.2.2"
@@ -782,54 +723,12 @@
       "version": "19.2.3",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
       "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT",
       "peerDependencies": {
         "@types/react": "^19.2.0"
       }
     },
-    "node_modules/@xyflow/react": {
-      "version": "12.11.0",
-      "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz",
-      "integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==",
-      "license": "MIT",
-      "dependencies": {
-        "@xyflow/system": "0.0.77",
-        "classcat": "^5.0.3",
-        "zustand": "^4.4.0"
-      },
-      "peerDependencies": {
-        "@types/react": ">=17",
-        "@types/react-dom": ">=17",
-        "react": ">=17",
-        "react-dom": ">=17"
-      },
-      "peerDependenciesMeta": {
-        "@types/react": {
-          "optional": true
-        },
-        "@types/react-dom": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/@xyflow/system": {
-      "version": "0.0.77",
-      "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz",
-      "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/d3-drag": "^3.0.7",
-        "@types/d3-interpolate": "^3.0.4",
-        "@types/d3-selection": "^3.0.10",
-        "@types/d3-transition": "^3.0.8",
-        "@types/d3-zoom": "^3.0.8",
-        "d3-drag": "^3.0.0",
-        "d3-interpolate": "^3.0.1",
-        "d3-selection": "^3.0.0",
-        "d3-zoom": "^3.0.0"
-      }
-    },
     "node_modules/caniuse-lite": {
       "version": "1.0.30001797",
       "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
@@ -850,12 +749,6 @@
       ],
       "license": "CC-BY-4.0"
     },
-    "node_modules/classcat": {
-      "version": "5.0.5",
-      "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
-      "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
-      "license": "MIT"
-    },
     "node_modules/client-only": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -866,124 +759,9 @@
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
       "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT"
     },
-    "node_modules/d3-color": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
-      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-dispatch": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
-      "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-drag": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
-      "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-dispatch": "1 - 3",
-        "d3-selection": "3"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-ease": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
-      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
-      "license": "BSD-3-Clause",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-interpolate": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
-      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-color": "1 - 3"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-selection": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
-      "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-timer": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
-      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-transition": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
-      "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-color": "1 - 3",
-        "d3-dispatch": "1 - 3",
-        "d3-ease": "1 - 3",
-        "d3-interpolate": "1 - 3",
-        "d3-timer": "1 - 3"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "peerDependencies": {
-        "d3-selection": "2 - 3"
-      }
-    },
-    "node_modules/d3-zoom": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
-      "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-dispatch": "1 - 3",
-        "d3-drag": "2 - 3",
-        "d3-interpolate": "1 - 3",
-        "d3-selection": "2 - 3",
-        "d3-transition": "2 - 3"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/dagre": {
-      "version": "0.8.5",
-      "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
-      "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
-      "license": "MIT",
-      "dependencies": {
-        "graphlib": "^2.1.8",
-        "lodash": "^4.17.15"
-      }
-    },
     "node_modules/detect-libc": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -994,21 +772,6 @@
         "node": ">=8"
       }
     },
-    "node_modules/graphlib": {
-      "version": "2.1.8",
-      "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
-      "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
-      "license": "MIT",
-      "dependencies": {
-        "lodash": "^4.17.15"
-      }
-    },
-    "node_modules/lodash": {
-      "version": "4.18.1",
-      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
-      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
-      "license": "MIT"
-    },
     "node_modules/lucide-react": {
       "version": "0.468.0",
       "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
@@ -1265,43 +1028,6 @@
       "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
       "dev": true,
       "license": "MIT"
-    },
-    "node_modules/use-sync-external-store": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
-      "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
-      "license": "MIT",
-      "peerDependencies": {
-        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
-      }
-    },
-    "node_modules/zustand": {
-      "version": "4.5.7",
-      "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
-      "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
-      "license": "MIT",
-      "dependencies": {
-        "use-sync-external-store": "^1.2.2"
-      },
-      "engines": {
-        "node": ">=12.7.0"
-      },
-      "peerDependencies": {
-        "@types/react": ">=16.8",
-        "immer": ">=9.0.6",
-        "react": ">=16.8"
-      },
-      "peerDependenciesMeta": {
-        "@types/react": {
-          "optional": true
-        },
-        "immer": {
-          "optional": true
-        },
-        "react": {
-          "optional": true
-        }
-      }
     }
   }
 }

+ 0 - 3
web/package.json

@@ -8,15 +8,12 @@
     "build": "next build"
   },
   "dependencies": {
-    "@xyflow/react": "^12.11.0",
-    "dagre": "^0.8.5",
     "lucide-react": "^0.468.0",
     "next": "^15.1.0",
     "react": "^19.0.0",
     "react-dom": "^19.0.0"
   },
   "devDependencies": {
-    "@types/dagre": "^0.7.54",
     "@types/node": "^22.10.0",
     "@types/react": "^19.0.0",
     "@types/react-dom": "^19.0.0",