Bläddra i källkod

fix: dashboard filter

Talegorithm 1 månad sedan
förälder
incheckning
9f2fd7ae3a
27 ändrade filer med 1969 tillägg och 560 borttagningar
  1. 131 20
      knowhub/frontend/src/components/dashboard/CategoryTree.tsx
  2. 26 33
      knowhub/frontend/src/components/dashboard/cards/RelationCard.tsx
  3. 2 2
      knowhub/frontend/src/components/dashboard/details/DrawerContentComponents.tsx
  4. 3 3
      knowhub/frontend/src/components/layout/Navbar.tsx
  5. 72 0
      knowhub/frontend/src/components/layout/VersionSwitcher.tsx
  6. 17 10
      knowhub/frontend/src/hooks/useDashboardData.ts
  7. 98 70
      knowhub/frontend/src/hooks/useDashboardFilter.ts
  8. 55 32
      knowhub/frontend/src/lib/dashboardGraph.ts
  9. 321 338
      knowhub/frontend/src/pages/Dashboard.tsx
  10. 44 4
      knowhub/frontend/src/services/api.ts
  11. 12 6
      knowhub/knowhub_db/pg_capability_store.py
  12. 19 9
      knowhub/knowhub_db/pg_requirement_store.py
  13. 10 7
      knowhub/knowhub_db/pg_resource_store.py
  14. 17 3
      knowhub/knowhub_db/pg_store.py
  15. 18 10
      knowhub/knowhub_db/pg_strategy_store.py
  16. 61 0
      knowhub/knowhub_db/version_context.py
  17. 68 0
      knowhub/scripts/taodev_backfill_missing_tools.py
  18. 91 0
      knowhub/scripts/taodev_fill_capability_tool.py
  19. 259 0
      knowhub/scripts/taodev_ingest.py
  20. 82 0
      knowhub/scripts/taodev_probe.py
  21. 84 0
      knowhub/scripts/version_investigate.py
  22. 45 0
      knowhub/scripts/version_investigate2.py
  23. 58 0
      knowhub/scripts/version_step1_rename.py
  24. 214 0
      knowhub/scripts/version_step2_duplicate_dev_dedup.py
  25. 98 0
      knowhub/scripts/version_step3_verify.py
  26. 30 0
      knowhub/scripts/version_store_smoke_test.py
  27. 34 13
      knowhub/server.py

+ 131 - 20
knowhub/frontend/src/components/dashboard/CategoryTree.tsx

@@ -1,4 +1,4 @@
-import { useState, useRef, useEffect } from 'react';
+import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
 import { createPortal } from 'react-dom';
 import { cn } from '../../lib/utils';
 import { ChevronRight, ChevronDown, ChevronLeft, ZoomIn, ZoomOut, Maximize, FolderTree, FileText } from 'lucide-react';
@@ -7,7 +7,7 @@ interface NodeProps {
   node: any;
   onSelect: (node: any) => void;
   onOpenDetail?: (node: any) => void;
-  selectedId: string | number | null;
+  selectedIds: Set<string>;
   level: number;
   highlightLeafNames: Set<string> | null; // null = no filter active
   subtreeHighlightNodeIds: Set<string> | null;
@@ -17,6 +17,8 @@ interface NodeProps {
   dimensionColor: string; // hex color for the node's dimension
   focusedTreeNodeId?: string | number | null;
   focusTrigger?: number;
+  isOpen: (nodeId: string) => boolean;
+  onToggleOpen: (nodeId: string) => void;
 }
 
 // Returns true if this node or any descendant is in the highlight set
@@ -27,8 +29,8 @@ function nodeHasHighlightedLeaf(node: any, highlightLeafNames: Set<string> | nul
   return node.children.some((child: any) => nodeHasHighlightedLeaf(child, highlightLeafNames));
 }
 
-function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedId, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeIds, patternNodeIds, nodeMetricsMap, dimensionColor, focusedTreeNodeId = null, focusTrigger = 0 }: NodeProps) {
-  const [expanded, setExpanded] = useState(true);
+function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedIds, level, highlightLeafNames, subtreeHighlightNodeIds, sourceNodeIds, patternNodeIds, nodeMetricsMap, dimensionColor, focusedTreeNodeId = null, focusTrigger = 0, isOpen, onToggleOpen }: NodeProps) {
+  const expanded = isOpen(String(node.id));
   const [hoveredMetric, setHoveredMetric] = useState<null | { key: string; count: number; colorClass: string; x: number; y: number }>(null);
   const nodeRef = useRef<HTMLDivElement>(null);
   const hasChildren = node.children && node.children.length > 0;
@@ -44,11 +46,9 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedId, level, h
   const isPatternNode = patternNodeIds?.has(String(node.id)) ?? false;
   const isAssociatedNode = isSourceNode || isPatternNode;
   const isSelected =
-    selectedId !== null &&
-    selectedId !== undefined &&
     node.id !== null &&
     node.id !== undefined &&
-    String(selectedId) === String(node.id);
+    selectedIds.has(String(node.id));
   const shouldScrollIntoView =
     focusedTreeNodeId !== null &&
     focusedTreeNodeId !== undefined &&
@@ -71,13 +71,10 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedId, level, h
           "flex items-center px-3 py-1.5 rounded-md border shadow-[0_1px_2px_rgba(0,0,0,0.05)] cursor-pointer whitespace-nowrap transition-all z-10 h-[34px] bg-white border-slate-200",
           isSelected
             ? "ring-2 ring-orange-400 ring-offset-2 border-orange-400 shadow-[0_0_0_2px_rgba(251,146,60,0.16)]"
-            : isAssociatedNode
-            ? "ring-2 ring-sky-400 ring-offset-1 border-sky-300 shadow-[0_0_0_2px_rgba(56,189,248,0.16)]"
-            : isInSubtreeHighlight
-            ? "border-sky-300 border-dashed bg-white shadow-none"
-            : isLeafHighlighted
-            ? "ring-2 ring-sky-300 ring-offset-1 border-sky-300"
-            : "hover:brightness-95"
+            : isAssociatedNode || isInSubtreeHighlight || isLeafHighlighted
+              // 与 RelationCard 的 direct-match 样式统一:实线 sky 边 + sky-50 浅底 + 外发光
+              ? "border-sky-300 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
+              : "hover:brightness-95"
         )}
         onClick={() => onSelect(node)}
       >
@@ -126,7 +123,7 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedId, level, h
         {hasChildren && (
           <button
             className="ml-2 px-1 text-slate-400 hover:text-slate-700 focus:outline-none transition-transform"
-            onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
+            onClick={(e) => { e.stopPropagation(); onToggleOpen(String(node.id)); }}
           >
             {expanded ? <ChevronDown size={14} className="opacity-70" /> : <ChevronRight size={14} className="opacity-70" />}
           </button>
@@ -154,7 +151,7 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedId, level, h
                 node={child}
                 onSelect={onSelect}
                 onOpenDetail={onOpenDetail}
-                selectedId={selectedId}
+                selectedIds={selectedIds}
                 level={level + 1}
                 highlightLeafNames={highlightLeafNames}
                 subtreeHighlightNodeIds={subtreeHighlightNodeIds}
@@ -163,6 +160,8 @@ function HorizontalTreeNode({ node, onSelect, onOpenDetail, selectedId, level, h
                 nodeMetricsMap={nodeMetricsMap}
                 dimensionColor={dimensionColor}
                 focusedTreeNodeId={focusedTreeNodeId}
+                isOpen={isOpen}
+                onToggleOpen={onToggleOpen}
               />
             </div>
           ))}
@@ -185,7 +184,7 @@ export function CategoryTree({
   data,
   onSelect,
   onOpenDetail,
-  selectedId,
+  selectedIds,
   highlightLeafNames = null,
   subtreeHighlightNodeIds = null,
   sourceNodeIds = null,
@@ -205,7 +204,7 @@ export function CategoryTree({
   data: any;
   onSelect: (node: any) => void;
   onOpenDetail?: (node: any) => void;
-  selectedId: any;
+  selectedIds: Set<string>;
   highlightLeafNames?: Set<string> | null;
   subtreeHighlightNodeIds?: Set<string> | null;
   sourceNodeIds?: Set<string> | null;
@@ -224,6 +223,94 @@ export function CategoryTree({
 }) {
   const [scale, setScale] = useState(1);
 
+  // 展开状态:defaultOpen 是基线,openStates 记录显式覆盖
+  const [defaultOpen, setDefaultOpen] = useState(true);
+  const [openStates, setOpenStates] = useState<Map<string, boolean>>(new Map());
+  const [autoCollapse, setAutoCollapse] = useState(false);
+
+  const isOpen = useCallback((id: string) => {
+    if (openStates.has(id)) return openStates.get(id)!;
+    return defaultOpen;
+  }, [openStates, defaultOpen]);
+
+  const onToggleOpen = useCallback((id: string) => {
+    setOpenStates(prev => {
+      const next = new Map(prev);
+      const current = next.has(id) ? next.get(id)! : defaultOpen;
+      next.set(id, !current);
+      return next;
+    });
+  }, [defaultOpen]);
+
+  // 从根路径找到目标节点的所有祖先 id(不含目标自己)
+  const findAncestorIds = useMemo(() => {
+    return (targetId: string): string[] => {
+      if (!data?.children) return [];
+      const walk = (node: any, path: string[]): string[] | null => {
+        if (String(node.id) === targetId) return path;
+        if (!node.children) return null;
+        for (const child of node.children) {
+          const r = walk(child, [...path, String(node.id)]);
+          if (r) return r;
+        }
+        return null;
+      };
+      for (const top of data.children) {
+        const r = walk(top, []);
+        if (r) return r;
+      }
+      return [];
+    };
+  }, [data]);
+
+  const expandAll = () => {
+    setDefaultOpen(true);
+    setOpenStates(new Map());
+  };
+  const collapseAll = () => {
+    setDefaultOpen(false);
+    setOpenStates(new Map());
+  };
+  const toggleAutoCollapse = () => {
+    setAutoCollapse(v => {
+      const nextAuto = !v;
+      if (nextAuto && focusedTreeNodeId != null) {
+        // 切换到自动折叠模式:立刻执行"全折叠 + 展开当前焦点的根通路"
+        const ancestors = findAncestorIds(String(focusedTreeNodeId));
+        setDefaultOpen(false);
+        const next = new Map<string, boolean>();
+        ancestors.forEach(id => next.set(id, true));
+        setOpenStates(next);
+      }
+      return nextAuto;
+    });
+  };
+
+  // 焦点变化时展开路径
+  useEffect(() => {
+    if (focusedTreeNodeId == null) return;
+    const ancestors = findAncestorIds(String(focusedTreeNodeId));
+    if (ancestors.length === 0) return;
+
+    if (autoCollapse) {
+      setDefaultOpen(false);
+      const next = new Map<string, boolean>();
+      ancestors.forEach(id => next.set(id, true));
+      setOpenStates(next);
+    } else {
+      // 只在树处于折叠状态时才加路径;全展开状态下是 no-op
+      setOpenStates(prev => {
+        let changed = false;
+        const next = new Map(prev);
+        ancestors.forEach(id => {
+          const cur = next.has(id) ? next.get(id)! : defaultOpen;
+          if (!cur) { next.set(id, true); changed = true; }
+        });
+        return changed ? next : prev;
+      });
+    }
+  }, [focusedTreeNodeId, autoCollapse, findAncestorIds]);
+
   if (!data || !data.children) return (
     <div className="bg-slate-100/50 rounded-2xl border border-slate-200 flex flex-col items-center justify-center min-h-[400px] text-slate-400">
       <div className="w-8 h-8 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
@@ -272,6 +359,28 @@ export function CategoryTree({
               </button>
             </div>
           )}
+          <button
+            onClick={expandAll}
+            className="text-[10px] font-bold px-2 py-0.5 rounded bg-slate-200 text-slate-500 hover:bg-slate-300 transition-colors"
+          >
+            全展开
+          </button>
+          <button
+            onClick={collapseAll}
+            className="text-[10px] font-bold px-2 py-0.5 rounded bg-slate-200 text-slate-500 hover:bg-slate-300 transition-colors"
+          >
+            全折叠
+          </button>
+          <button
+            onClick={toggleAutoCollapse}
+            className={cn(
+              "text-[10px] font-bold px-2 py-0.5 rounded transition-colors",
+              autoCollapse ? "bg-indigo-100 text-indigo-600" : "bg-slate-200 text-slate-500 hover:bg-slate-300"
+            )}
+            title="开启后:每次定位到新节点都先全折叠再展开该节点的根路径"
+          >
+            自动折叠
+          </button>
           <button
             onClick={onToggleWideMode}
             className={cn(
@@ -279,7 +388,7 @@ export function CategoryTree({
               wideMode ? "bg-indigo-100 text-indigo-600" : "bg-slate-200 text-slate-500 hover:bg-slate-300"
             )}
           >
-            {wideMode ? '窄模式' : '宽模式'}
+            {wideMode ? '宽模式' : '窄模式'}
           </button>
           <span className="text-slate-400">{totalNodeCount ?? 0}</span>
         </div>
@@ -332,7 +441,7 @@ export function CategoryTree({
                         node={subNode}
                         onSelect={onSelect}
                         onOpenDetail={onOpenDetail}
-                        selectedId={selectedId}
+                        selectedIds={selectedIds}
                         level={1}
                         highlightLeafNames={highlightLeafNames}
                         subtreeHighlightNodeIds={subtreeHighlightNodeIds}
@@ -341,6 +450,8 @@ export function CategoryTree({
                         nodeMetricsMap={nodeMetricsMap}
                         dimensionColor={color.hex}
                         focusedTreeNodeId={focusedTreeNodeId}
+                        isOpen={isOpen}
+                        onToggleOpen={onToggleOpen}
                       />
                     ))}
                   </div>

+ 26 - 33
knowhub/frontend/src/components/dashboard/cards/RelationCard.tsx

@@ -6,7 +6,7 @@ import { DASHBOARD_COLUMN_THEME } from '../../../lib/dashboard-theme';
 export function RelationCard({
   type,
   item,
-  activeId,
+  isSelected = false,
   shouldScrollIntoView = false,
   selectedLeafNames,
   directMatch = false,
@@ -20,7 +20,7 @@ export function RelationCard({
 }: {
   type: string;
   item: any;
-  activeId: string | null;
+  isSelected?: boolean;
   shouldScrollIntoView?: boolean;
   selectedLeafNames?: Set<string>;
   directMatch?: boolean;
@@ -34,7 +34,6 @@ export function RelationCard({
 }) {
   const cardRef = useRef<HTMLDivElement | null>(null);
   const nodeId = `${type}:${item.id}`;
-  const isSelected = activeId === nodeId;
 
   const formatSourceNodeTag = (sn: any): { ref: string; label: string } | null => {
     const rawRef = typeof sn === 'object'
@@ -49,8 +48,8 @@ export function RelationCard({
 
   const allSourceNodeTags: Array<{ ref: string; label: string }> = type === 'req'
     ? (item.source_nodes || [])
-        .map((sn: any) => formatSourceNodeTag(sn))
-        .filter((tag: { ref: string; label: string } | null): tag is { ref: string; label: string } => Boolean(tag))
+      .map((sn: any) => formatSourceNodeTag(sn))
+      .filter((tag: { ref: string; label: string } | null): tag is { ref: string; label: string } => Boolean(tag))
     : [];
   const sourceNodeTags = showAllSourceTags ? allSourceNodeTags : allSourceNodeTags.slice(0, 3);
   const totalSourceNodes = type === 'req' ? allSourceNodeTags.length : 0;
@@ -59,15 +58,15 @@ export function RelationCard({
   const label = item.name || item.description || item.task || item.id;
 
   const typeColors: Record<string, { accent: string; tagBg: string; tagText: string; leftBar: string }> = {
-    req:  { accent: DASHBOARD_COLUMN_THEME.req.cardAccent, tagBg: DASHBOARD_COLUMN_THEME.req.tagBg, tagText: DASHBOARD_COLUMN_THEME.req.tagText, leftBar: 'bg-cyan-400' },
+    req: { accent: DASHBOARD_COLUMN_THEME.req.cardAccent, tagBg: DASHBOARD_COLUMN_THEME.req.tagBg, tagText: DASHBOARD_COLUMN_THEME.req.tagText, leftBar: 'bg-cyan-400' },
     proc: { accent: DASHBOARD_COLUMN_THEME.proc.cardAccent, tagBg: DASHBOARD_COLUMN_THEME.proc.tagBg, tagText: DASHBOARD_COLUMN_THEME.proc.tagText, leftBar: 'bg-green-400' },
-    cap:  { accent: DASHBOARD_COLUMN_THEME.cap.cardAccent, tagBg: DASHBOARD_COLUMN_THEME.cap.tagBg, tagText: DASHBOARD_COLUMN_THEME.cap.tagText, leftBar: 'bg-amber-400' },
+    cap: { accent: DASHBOARD_COLUMN_THEME.cap.cardAccent, tagBg: DASHBOARD_COLUMN_THEME.cap.tagBg, tagText: DASHBOARD_COLUMN_THEME.cap.tagText, leftBar: 'bg-amber-400' },
     tool: { accent: DASHBOARD_COLUMN_THEME.tool.cardAccent, tagBg: DASHBOARD_COLUMN_THEME.tool.tagBg, tagText: DASHBOARD_COLUMN_THEME.tool.tagText, leftBar: 'bg-orange-400' },
     know: { accent: DASHBOARD_COLUMN_THEME.proc.cardAccent, tagBg: DASHBOARD_COLUMN_THEME.proc.tagBg, tagText: DASHBOARD_COLUMN_THEME.proc.tagText, leftBar: 'bg-green-400' },
   };
   const tc = typeColors[type] ?? typeColors.req;
   const cardMetrics = metrics || { nodeCount: 0, reqCount: 0, procCount: 0, capCount: 0, toolCount: 0, patternCount: 0 };
-  
+
   const metricItems = [
     { key: 'node', count: cardMetrics.nodeCount, className: 'bg-slate-500 text-white' },
     { key: 'pattern', count: cardMetrics.patternCount, className: DASHBOARD_COLUMN_THEME.pattern.metric },
@@ -76,7 +75,7 @@ export function RelationCard({
     { key: 'cap', count: cardMetrics.capCount, className: DASHBOARD_COLUMN_THEME.cap.metric },
     { key: 'tool', count: cardMetrics.toolCount, className: DASHBOARD_COLUMN_THEME.tool.metric },
   ];
-  
+
   const metricDots = (
     <>
       {metricItems.map((metric) => (
@@ -102,35 +101,29 @@ export function RelationCard({
   return (
     <div
       ref={cardRef}
-      onClick={() => {
-        if (dimmed) return;
-        onSingleClick(nodeId);
-      }}
+      onClick={() => onSingleClick(nodeId)}
       className={cn(
         "group relative p-3 rounded-xl cursor-pointer mb-2 select-none bg-white transition-all border-l-4",
-        tc.accent,
+        // indirect / default 保留 column identity 的左侧强调色
+        !isSelected && !directMatch && !dimmed && tc.accent,
         isSelected
-          ? "border border-orange-400 border-l-4 shadow-[0_0_0_1px_rgba(251,146,60,0.7)] z-10"
+          ? "border-l-orange-400 border border-orange-400 shadow-[0_0_0_1px_rgba(251,146,60,0.7)] z-10"
           : directMatch
-          ? "border border-sky-300 border-l-4 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
-          : dimmed
-          ? "border border-slate-200 border-l-4 bg-slate-50 opacity-45 saturate-50"
-          : "border border-transparent border-l-4 hover:border-slate-200"
-      ,
-        dimmed && "cursor-not-allowed"
+            // direct 状态:所有列统一 sky 左条,避免各列蓝调不一
+            ? "border-l-sky-400 border border-sky-300 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
+            : dimmed
+              // 无关联:置灰但可点击
+              ? "border-l-slate-200 border border-slate-200 bg-slate-50 opacity-45 saturate-50"
+              : "border border-transparent hover:border-slate-200"
       )}
     >
       {onOpenDetail && (
         <button
           onClick={(e) => {
             e.stopPropagation();
-            if (dimmed) return;
             onOpenDetail(item);
           }}
-          className={cn(
-            "absolute right-2 top-2 p-1.5 rounded-md text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors",
-            dimmed && "cursor-not-allowed hover:bg-transparent"
-          )}
+          className="absolute right-2 top-2 p-1.5 rounded-md text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
           title="查看详情"
         >
           <FileText size={14} />
@@ -167,19 +160,19 @@ export function RelationCard({
               {sourceNodeTags.map((tag) => {
                 const isHighlighted = selectedLeafNames && selectedLeafNames.has(tag.label);
                 return (
-                  <span key={tag.ref} 
+                  <span key={tag.ref}
                     onClick={(e) => {
-                      if (dimmed) return;
                       if (onSourceNodeClick) {
                         e.stopPropagation();
-                        onSourceNodeClick(tag.ref);
+                        // 传叶子名而非 path,与 pattern 卡 leaf_names 点击保持一致
+                        onSourceNodeClick(tag.label);
                       }
                     }}
                     className={cn(
-                    "text-[9px] px-1.5 py-0.5 rounded-md font-medium truncate max-w-[100px]",
-                    isHighlighted ? "bg-sky-100 text-sky-700 ring-1 ring-sky-400 font-bold" : cn(tc.tagBg, tc.tagText),
-                    onSourceNodeClick && !dimmed && "cursor-pointer hover:ring-1 hover:ring-cyan-300"
-                  )}>
+                      "text-[9px] px-1.5 py-0.5 rounded-md font-medium truncate max-w-[100px]",
+                      isHighlighted ? "bg-sky-100 text-sky-700 ring-1 ring-sky-400 font-bold" : cn(tc.tagBg, tc.tagText),
+                      onSourceNodeClick && "cursor-pointer hover:ring-1 hover:ring-cyan-300"
+                    )}>
                     {tag.label}
                   </span>
                 );

+ 2 - 2
knowhub/frontend/src/components/dashboard/details/DrawerContentComponents.tsx

@@ -5,7 +5,7 @@ import { cn } from '../../../lib/utils';
 import { getResource, batchGetPosts } from '../../../services/api';
 
 // ─── 详情抽屉内容 ──────────────────────────────────────────────────────────────
-export function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap, selectedReqId }: { type: string; data: any; dbData: any; onOpenPost: (postId: string, post: any) => void; nodePostsMap?: Record<string, string[]>; selectedReqId?: string | null }) {
+export function DrawerContent({ type, data, dbData, onOpenPost, nodePostsMap }: { type: string; data: any; dbData: any; onOpenPost: (postId: string, post: any) => void; nodePostsMap?: Record<string, string[]> }) {
   if (type === 'itemset') {
     return (
       <ItemsetPostsDrawer
@@ -768,7 +768,7 @@ export function ItemsetPostsDrawer({
   const [posts, setPosts] = useState<Record<string, any>>({});
   const [loading, setLoading] = useState(false);
 
-  const postIds = itemset.post_ids || [];
+  const postIds = itemset.matched_post_ids || itemset.post_ids || [];
 
   useEffect(() => {
     if (postIds.length === 0) return;

+ 3 - 3
knowhub/frontend/src/components/layout/Navbar.tsx

@@ -55,9 +55,9 @@ export function Navbar({ activeTab, onTabChange }: NavbarProps) {
       <div className="flex items-center gap-4">
         <div className="relative">
           <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
-          <input 
-            type="text" 
-            placeholder="Search KnowHub..." 
+          <input
+            type="text"
+            placeholder="Search KnowHub..."
             className="bg-slate-50 border border-transparent rounded-xl py-2 pl-10 pr-4 w-64 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:bg-white focus:border-indigo-100 transition-all text-slate-700 placeholder:text-slate-400"
           />
         </div>

+ 72 - 0
knowhub/frontend/src/components/layout/VersionSwitcher.tsx

@@ -0,0 +1,72 @@
+import { useState } from 'react';
+import { Database, Check, ChevronDown } from 'lucide-react';
+import { SUPPORTED_VERSIONS, getActiveVersion, setActiveVersion, type DataVersion } from '../../services/api';
+import { cn } from '../../lib/utils';
+
+const VERSION_LABEL: Record<DataVersion, { title: string; desc: string }> = {
+  tao_dev:      { title: 'tao_dev',      desc: '原始入库(不去重、LLM raw cap)' },
+  dev_dedup:    { title: 'dev_dedup',    desc: '去重后未抽象(99 concrete strategy)' },
+  dev_abstract: { title: 'dev_abstract', desc: '抽象化后(26 pattern + knowledge)' },
+};
+
+export function VersionSwitcher() {
+  const [open, setOpen] = useState(false);
+  const [current, setCurrent] = useState<DataVersion>(getActiveVersion());
+
+  const onPick = (v: DataVersion) => {
+    if (v === current) { setOpen(false); return; }
+    setActiveVersion(v);
+    setCurrent(v);
+    setOpen(false);
+    // 切版本强制整页刷新:最稳,所有内存状态 + 缓存彻底失效
+    window.location.reload();
+  };
+
+  return (
+    <div className="relative">
+      <button
+        onClick={() => setOpen(o => !o)}
+        className="h-8 px-3 flex items-center gap-2 rounded-xl bg-slate-50 border border-slate-100 text-xs font-medium text-slate-600 hover:text-indigo-600 hover:border-indigo-100 transition-colors"
+        title="切换数据版本"
+      >
+        <Database size={14} />
+        <span className="tabular-nums">{current}</span>
+        <ChevronDown size={12} className={cn('transition-transform', open && 'rotate-180')} />
+      </button>
+      {open && (
+        <>
+          <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
+          <div className="absolute left-0 top-9 z-50 w-72 bg-white rounded-xl shadow-lg shadow-slate-200/60 border border-slate-100 p-1.5">
+            <div className="px-2 py-1.5 text-[10px] font-bold uppercase tracking-wider text-slate-400">
+              数据版本
+            </div>
+            {SUPPORTED_VERSIONS.map(v => {
+              const info = VERSION_LABEL[v];
+              const active = v === current;
+              return (
+                <button
+                  key={v}
+                  onClick={() => onPick(v)}
+                  className={cn(
+                    'w-full text-left px-3 py-2 rounded-lg flex items-start gap-2 transition-colors',
+                    active ? 'bg-indigo-50' : 'hover:bg-slate-50'
+                  )}
+                >
+                  <div className="mt-0.5 w-4 h-4 flex items-center justify-center">
+                    {active && <Check size={14} className="text-indigo-600" />}
+                  </div>
+                  <div className="flex-1 min-w-0">
+                    <div className={cn('text-sm font-semibold', active ? 'text-indigo-700' : 'text-slate-700')}>
+                      {info.title}
+                    </div>
+                    <div className="text-[11px] text-slate-500 mt-0.5">{info.desc}</div>
+                  </div>
+                </button>
+              );
+            })}
+          </div>
+        </>
+      )}
+    </div>
+  );
+}

+ 17 - 10
knowhub/frontend/src/hooks/useDashboardData.ts

@@ -69,13 +69,17 @@ export const useDashboardData = () => {
         let know: any[];
 
         try {
-          const snapshot = await getDashboardSnapshot();
+          const [snapshot, reqRes] = await Promise.all([
+            getDashboardSnapshot(),
+            getRequirements(1000, 0).catch(() => null)
+          ]);
           data = snapshot.tree;
-          reqs = snapshot.reqs || [];
+          reqs = (reqRes && reqRes.results) ? reqRes.results : (snapshot.reqs || []);
           caps = snapshot.caps || [];
           tools = snapshot.tools || [];
           procs = snapshot.procs || [];
           know = snapshot.know || [];
+          know = snapshot.know || [];
         } catch {
           setLoadingText('回退模式:并行获取底座数据...');
           const [treeRes, reqRes, capRes, toolRes, procRes, knowRes] = await Promise.all([
@@ -94,14 +98,17 @@ export const useDashboardData = () => {
           know = knowRes.results || [];
         }
 
-        // Fetch Static Asset Patterns
-        Promise.allSettled([
-          getItemsetsAll(),
-          getRequirementsPlanB()
-        ]).then(([itemsetsRes, planBRes]) => {
-          if (itemsetsRes.status === 'fulfilled') setAllItemsets(itemsetsRes.value.itemsets || []);
-          if (planBRes.status === 'fulfilled') setReqPlanBData(planBRes.value);
-        });
+        // Pattern itemsets:纳入首屏 loading gate(原先是 fire-and-forget,会导致 Pattern 列空)
+        setLoadingText('加载 Pattern 数据...');
+        try {
+          const itemsetsRes = await getItemsetsAll();
+          setAllItemsets(itemsetsRes.itemsets || []);
+        } catch {
+          // 外部 pattern 服务挂掉不阻塞 dashboard;allItemsets 保持空数组
+        }
+
+        // PlanB 是降级兜底、不影响 Pattern 列渲染,保留 fire-and-forget
+        getRequirementsPlanB().then(d => setReqPlanBData(d)).catch(() => {});
 
         // Mapping Logic
         const nameToNode: Record<string, any> = {};

+ 98 - 70
knowhub/frontend/src/hooks/useDashboardFilter.ts

@@ -7,9 +7,18 @@ export type MultiSelectMode = Record<NodeType, 'AND' | 'OR'>;
 export interface ItemState {
   id: string;
   type: NodeType;
-  status: 'active' | 'matched' | 'dimmed';
+  status: 'active' | 'matched' | 'indirect' | 'dimmed';
 }
 
+const emptySelectionState = (): SelectionState => ({
+  tree: new Set(),
+  pattern: new Set(),
+  req: new Set(),
+  proc: new Set(),
+  cap: new Set(),
+  tool: new Set(),
+});
+
 export function useDashboardFilter(graph: DashboardGraph | null) {
   const [selections, setSelections] = useState<SelectionState>({
     tree: new Set(),
@@ -30,26 +39,42 @@ export function useDashboardFilter(graph: DashboardGraph | null) {
   });
 
   // Action helpers
+  // 复选仅限同 rank:当用户在另一个 rank 上添加选择时,其他 rank 的选择会被清空
   const toggleSelection = (type: NodeType, id: string) => {
     setSelections(prev => {
-      const newSet = new Set(prev[type]);
-      if (newSet.has(id)) newSet.delete(id);
-      else newSet.add(id);
+      const current = prev[type];
+      const isAdding = !current.has(id);
+
+      if (isAdding) {
+        const hasOtherTypeSelection = (Object.keys(prev) as NodeType[])
+          .some(t => t !== type && prev[t].size > 0);
+        if (hasOtherTypeSelection) {
+          const next = emptySelectionState();
+          next[type] = new Set(current);
+          next[type].add(id);
+          return next;
+        }
+      }
+
+      const newSet = new Set(current);
+      if (isAdding) newSet.add(id);
+      else newSet.delete(id);
       return { ...prev, [type]: newSet };
     });
   };
 
-  const clearAll = () => {
-    setSelections({
-      tree: new Set(),
-      pattern: new Set(),
-      req: new Set(),
-      proc: new Set(),
-      cap: new Set(),
-      tool: new Set(),
+  const setSingleSelection = (type: NodeType, id: string | null) => {
+    setSelections(() => {
+      const next = emptySelectionState();
+      if (id !== null) next[type].add(id);
+      return next;
     });
   };
 
+  const clearAll = () => {
+    setSelections(emptySelectionState());
+  };
+
   const toggleMultiMode = (type: NodeType) => {
     setMultiMode(prev => ({
       ...prev,
@@ -57,82 +82,83 @@ export function useDashboardFilter(graph: DashboardGraph | null) {
     }));
   };
 
-  // The engine
+  // The engine —— 同 rank 约束下,只会有一个 type 持有选择
   const filterResults = useMemo(() => {
-    if (!graph) return { active: new Set<string>(), matched: new Set<string>() };
-
-    let hasAnySelection = false;
-    const categoryReachability: Set<string>[] = [];
-    const activeRefs = new Set<string>();
-
-    for (const [type, activeSet] of Object.entries(selections)) {
-      if (activeSet.size === 0) continue;
-      hasAnySelection = true;
-      const t = type as NodeType;
-      const mode = multiMode[t];
-
-      const itemReachabilitySets: Set<string>[] = [];
-
-      for (const id of activeSet) {
-        const ref = DashboardGraph.makeRef(t, id);
-        activeRefs.add(ref);
-        
-        // Find reachability for this specific selected item (Up + Down)
-        const leftReach = graph.explore(new Set([ref]), 'upstream');
-        const rightReach = graph.explore(new Set([ref]), 'downstream');
-        
-        const combined = new Set([...leftReach, ...rightReach]);
-        itemReachabilitySets.push(combined);
-      }
+    const empty = {
+      active: new Set<string>(),
+      matched: new Set<string>(),
+      directMatched: new Set<string>(),
+      indirectMatched: new Set<string>(),
+    };
+    if (!graph) return empty;
 
-      // Combine items within the same category (AND vs OR)
-      if (itemReachabilitySets.length > 0) {
-        let catR: Set<string>;
-        if (mode === 'OR') {
-          catR = new Set();
-          itemReachabilitySets.forEach(s => s.forEach(ref => catR.add(ref)));
-        } else {
-          // AND mode (intersection)
-          catR = new Set(itemReachabilitySets[0]);
-          for (let i = 1; i < itemReachabilitySets.length; i++) {
-            const currentObj = itemReachabilitySets[i];
-            catR = new Set([...catR].filter(x => currentObj.has(x)));
-          }
-        }
-        categoryReachability.push(catR);
-      }
-    }
+    const activeType = (Object.keys(selections) as NodeType[])
+      .find(t => selections[t].size > 0);
 
-    if (!hasAnySelection) {
-      // Return ALL as matched if no selection
+    if (!activeType) {
+      // 未选择任何项:全部视为匹配
       return {
         active: new Set<string>(),
-        matched: new Set<string>(['ALL']), // Special flag indicating everything is matched
+        matched: new Set<string>(['ALL']),
+        directMatched: new Set<string>(['ALL']),
+        indirectMatched: new Set<string>(),
       };
     }
 
-    // Now cross-category is ALWAYS intersection
-    let validGraph = new Set(categoryReachability[0]);
-    for (let i = 1; i < categoryReachability.length; i++) {
-      const catR = categoryReachability[i];
-      validGraph = new Set([...validGraph].filter(x => catR.has(x)));
+    const activeIds = selections[activeType];
+    const mode = multiMode[activeType];
+    const activeRefs = new Set<string>();
+    const itemDirectSets: Set<string>[] = [];
+    const itemIndirectSets: Set<string>[] = [];
+
+    for (const id of activeIds) {
+      const ref = DashboardGraph.makeRef(activeType, id);
+      activeRefs.add(ref);
+      const up = graph.exploreTiers(new Set([ref]), 'upstream');
+      const down = graph.exploreTiers(new Set([ref]), 'downstream');
+      itemDirectSets.push(new Set([...up.direct, ...down.direct]));
+      itemIndirectSets.push(new Set([...up.indirect, ...down.indirect]));
     }
 
-    // The active items MUST be in the valid graph to be valid, but we inject them automatically
-    // or maybe they shouldn't be if paths cross differently. 
-    // Usually active UI components stay active by definition.
-    
+    let directMatched: Set<string>;
+    let indirectMatched: Set<string>;
+
+    if (mode === 'OR' || itemDirectSets.length === 1) {
+      directMatched = new Set();
+      indirectMatched = new Set();
+      itemDirectSets.forEach(s => s.forEach(r => directMatched.add(r)));
+      itemIndirectSets.forEach(s => s.forEach(r => indirectMatched.add(r)));
+    } else {
+      // AND: direct/indirect 分别取交集
+      directMatched = new Set(itemDirectSets[0]);
+      for (let i = 1; i < itemDirectSets.length; i++) {
+        directMatched = new Set([...directMatched].filter(r => itemDirectSets[i].has(r)));
+      }
+      indirectMatched = new Set(itemIndirectSets[0]);
+      for (let i = 1; i < itemIndirectSets.length; i++) {
+        indirectMatched = new Set([...indirectMatched].filter(r => itemIndirectSets[i].has(r)));
+      }
+    }
+
+    // direct 压倒 indirect:同一节点两边都有时取 direct
+    indirectMatched = new Set([...indirectMatched].filter(r => !directMatched.has(r)));
+    const matched = new Set<string>([...directMatched, ...indirectMatched]);
+
     return {
       active: activeRefs,
-      matched: validGraph,
+      matched,
+      directMatched,
+      indirectMatched,
     };
   }, [graph, selections, multiMode]);
 
   const getItemState = (type: NodeType, id: string): ItemState['status'] => {
-    if (!graph || filterResults.matched.has('ALL')) return 'matched';
+    // 未开启筛选时所有卡片保持默认态(indirect = 无特殊样式)
+    if (!graph || filterResults.matched.has('ALL')) return 'indirect';
     const ref = DashboardGraph.makeRef(type, id);
     if (filterResults.active.has(ref)) return 'active';
-    if (filterResults.matched.has(ref)) return 'matched';
+    if (filterResults.directMatched.has(ref)) return 'matched';
+    if (filterResults.indirectMatched.has(ref)) return 'indirect';
     return 'dimmed';
   };
 
@@ -140,9 +166,11 @@ export function useDashboardFilter(graph: DashboardGraph | null) {
     selections,
     multiMode,
     toggleSelection,
+    setSingleSelection,
     clearAll,
     toggleMultiMode,
     getItemState,
+    filterResults,
     hasActiveFilters: filterResults.active.size > 0
   };
 }

+ 55 - 32
knowhub/frontend/src/lib/dashboardGraph.ts

@@ -16,11 +16,9 @@ export const NODE_RANK: Record<NodeType, number> = {
 };
 
 export class DashboardGraph {
-  // graph representation: sourceId -> targetId -> true
-  private edges: Map<string, Set<string>> = new Map();
   private nodeTypes: Map<string, NodeType> = new Map();
+  private edges: Map<string, Set<string>> = new Map();
 
-  // Helper to standard format IDs just in case they collide (e.g. tool "GPT-4" vs cap "GPT-4")
   static makeRef(type: NodeType, id: string): string {
     return `${type}:::${id}`;
   }
@@ -33,13 +31,10 @@ export class DashboardGraph {
   addNode(type: NodeType, id: string) {
     const ref = DashboardGraph.makeRef(type, id);
     this.nodeTypes.set(ref, type);
-    if (!this.edges.has(ref)) {
-      this.edges.set(ref, new Set());
-    }
+    if (!this.edges.has(ref)) this.edges.set(ref, new Set());
     return ref;
   }
 
-  // Add an undirected edge (which is conceptually bidirectional for exploration)
   addEdge(refA: string, refB: string) {
     if (!this.edges.has(refA)) this.edges.set(refA, new Set());
     if (!this.edges.has(refB)) this.edges.set(refB, new Set());
@@ -56,40 +51,46 @@ export class DashboardGraph {
     return type ? NODE_RANK[type] : -1;
   }
 
+  getEdges(ref: string): Set<string> {
+    return new Set(this.edges.get(ref) || []);
+  }
+
   /**
-   * Explores the graph from a starting pivot without U-Turns.
-   * direction: 'upstream' (rank strictly decreasing) or 'downstream' (rank strictly increasing)
+   * Distance-based reachability in a single rank-monotonic direction.
+   * - 1 hop from any start → direct
+   * - 2+ hops → indirect
+   * Rank monotonicity (upstream: rank strictly decreases; downstream: strictly increases)
+   * forbids U-turns and same-rank traversal, so the BFS terminates naturally at rank bounds.
    */
-  explore(startRefs: Set<string>, direction: 'upstream' | 'downstream'): Set<string> {
-    const visited = new Set<string>();
-    const queue = Array.from(startRefs);
+  exploreTiers(startRefs: Set<string>, direction: 'upstream' | 'downstream'): { direct: Set<string>, indirect: Set<string> } {
+    const direct = new Set<string>();
+    const indirect = new Set<string>();
+    const visited = new Set<string>(startRefs);
+    const queue: Array<[string, number]> = [];
 
-    // Initial fill
-    for (const start of startRefs) {
-      visited.add(start);
-    }
+    for (const start of startRefs) queue.push([start, 0]);
 
     while (queue.length > 0) {
-      const current = queue.shift()!;
+      const [current, dist] = queue.shift()!;
       const currentRank = this.getRank(current);
       const neighbors = this.edges.get(current) || new Set();
 
       for (const neighbor of neighbors) {
         if (visited.has(neighbor)) continue;
-
         const neighborRank = this.getRank(neighbor);
+        const validDirection = (direction === 'upstream' && neighborRank < currentRank) ||
+                               (direction === 'downstream' && neighborRank > currentRank);
+        if (!validDirection) continue;
 
-        // strictly directional
-        if (direction === 'upstream' && neighborRank < currentRank) {
-          visited.add(neighbor);
-          queue.push(neighbor);
-        } else if (direction === 'downstream' && neighborRank > currentRank) {
-          visited.add(neighbor);
-          queue.push(neighbor);
-        }
+        visited.add(neighbor);
+        const newDist = dist + 1;
+        if (newDist === 1) direct.add(neighbor);
+        else indirect.add(neighbor);
+        queue.push([neighbor, newDist]);
       }
     }
-    return visited;
+
+    return { direct, indirect };
   }
 }
 
@@ -116,9 +117,6 @@ export function buildDashboardGraph(
   // Patterns
   allItemsets?.forEach((it) => G.addNode('pattern', String(it.id || it.hash)));
 
-  // Trees (Only Register nodes that actually have requirements mapped to them)
-  const treeNodes = new Set<string>();
-
   // 2. Build Edges
   
   // -- Req <-> Cap
@@ -189,8 +187,22 @@ export function buildDashboardGraph(
       (req.source_nodes || []).forEach((sn: any) => {
         const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name) : sn;
         if (nodeName) {
-          const treeRef = G.addNode('tree', String(nodeName));
-          G.addEdge(reqRef, treeRef);
+          const value = String(nodeName || '').trim();
+          const separator = value.includes('/') ? '/' : '>';
+          const parts = value.split(separator).map((part: string) => part.trim()).filter(Boolean).map((part: string) => part.replace(/^\/+|\/+$/g, ''));
+          const normalized = parts.join(' > ');
+          const matchedNodes = pathToNodesMap[normalized];
+          
+          if (matchedNodes && matchedNodes.length > 0) {
+            matchedNodes.forEach((n: any) => {
+              const treeRef = G.addNode('tree', String(n.id));
+              G.addEdge(reqRef, treeRef);
+            });
+          } else {
+            // Fallback for visual nodes if they aren't strictly mapped
+            const treeRef = G.addNode('tree', String(nodeName));
+            G.addEdge(reqRef, treeRef);
+          }
         }
       });
     }
@@ -249,5 +261,16 @@ export function buildDashboardGraph(
     }
   });
 
+  // -- Pattern <-> Tree (精确匹配:pattern.items[].category_id 即 tree node id)
+  allItemsets?.forEach(it => {
+    const patternRef = G.addNode('pattern', String(it.id || it.hash));
+    (it.items || []).forEach((item: any) => {
+      if (item && item.category_id != null) {
+        const treeRef = G.addNode('tree', String(item.category_id));
+        G.addEdge(patternRef, treeRef);
+      }
+    });
+  });
+
   return G;
 }

+ 321 - 338
knowhub/frontend/src/pages/Dashboard.tsx

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useRef, useCallback, Fragment, type React
 import { createPortal } from 'react-dom';
 import { Target, Wrench, FileText, ListTree, Cpu, X, ChevronLeft, ChevronRight } from 'lucide-react';
 import { CategoryTree } from '../components/dashboard/CategoryTree';
+import { VersionSwitcher } from '../components/layout/VersionSwitcher';
 import { SideDrawer } from '../components/common/SideDrawer';
 import { cn } from '../lib/utils';
 import { getResource, batchGetPosts } from '../services/api';
@@ -163,21 +164,21 @@ function CoverageFlowBoard({ data }: { data: any }) {
 
   const visibleCrossEdges = flowSelectedDetailNode
     ? activeCrossEdges
-        .filter((edge: any) => edge.source === flowSelectedDetailNode || edge.target === flowSelectedDetailNode)
-        .map((edge: any) => {
-          const selectedIsSource = edge.source === flowSelectedDetailNode;
-          const selectedIndex = selectedIsSource ? edge.sourceIndex : edge.targetIndex;
-          const otherIndex = selectedIsSource ? edge.targetIndex : edge.sourceIndex;
-          const selectedCenterX = dynamicLayouts.list[selectedIndex]?.cx || 0;
-          const otherCenterX = dynamicLayouts.list[otherIndex]?.cx || 0;
-          const selectedAnchorX = selectedIsSource ? selectedCenterX + 40 : selectedCenterX - 40;
-          const otherAnchorX = otherIndex < selectedIndex ? otherCenterX + 40 : otherCenterX - 40;
-          return {
-            ...edge,
-            sourceBadgeX: selectedIsSource ? selectedAnchorX : otherAnchorX,
-            targetBadgeX: selectedIsSource ? otherAnchorX : selectedAnchorX,
-          };
-        })
+      .filter((edge: any) => edge.source === flowSelectedDetailNode || edge.target === flowSelectedDetailNode)
+      .map((edge: any) => {
+        const selectedIsSource = edge.source === flowSelectedDetailNode;
+        const selectedIndex = selectedIsSource ? edge.sourceIndex : edge.targetIndex;
+        const otherIndex = selectedIsSource ? edge.targetIndex : edge.sourceIndex;
+        const selectedCenterX = dynamicLayouts.list[selectedIndex]?.cx || 0;
+        const otherCenterX = dynamicLayouts.list[otherIndex]?.cx || 0;
+        const selectedAnchorX = selectedIsSource ? selectedCenterX + 40 : selectedCenterX - 40;
+        const otherAnchorX = otherIndex < selectedIndex ? otherCenterX + 40 : otherCenterX - 40;
+        return {
+          ...edge,
+          sourceBadgeX: selectedIsSource ? selectedAnchorX : otherAnchorX,
+          targetBadgeX: selectedIsSource ? otherAnchorX : selectedAnchorX,
+        };
+      })
     : [];
 
   const canvasWidth = dynamicLayouts.totalWidth;
@@ -482,7 +483,7 @@ function CoverageFlowBoard({ data }: { data: any }) {
 // ─── 频繁模式列 ────────────────────────────────────────────────────────────────
 
 function PatternColumn({
-  selectedItemsetId,
+  selectedItemsetIds,
   itemsets,
   eligibleItemsetIds,
   metricsMap,
@@ -499,9 +500,9 @@ function PatternColumn({
   onFocusPrev,
   onFocusNext,
 }: {
-  selectedItemsetId: number | null;
+  selectedItemsetIds: Set<string>;
   itemsets: any[];
-  eligibleItemsetIds: Set<string>;
+  eligibleItemsetIds: { direct: Set<string>, indirect: Set<string> } | Set<string>;
   metricsMap: Record<string, { nodeCount: number; reqCount: number; procCount: number; capCount: number; toolCount: number }>;
   frozenOrder?: string[];
   contextNodeNames: Set<string>;
@@ -529,7 +530,7 @@ function PatternColumn({
       }
       return { ...itemset, matched_nodes: matchedNodes };
     });
-    if (selectedItemsetId !== null && frozenOrder && frozenOrder.length > 0) {
+    if (selectedItemsetIds.size > 0 && frozenOrder && frozenOrder.length > 0) {
       const rank = new Map<string, number>(frozenOrder.map((id, index) => [id, index]));
       return [...withMatches].sort((a, b) => {
         const aRank = rank.get(String(a.id));
@@ -540,70 +541,60 @@ function PatternColumn({
         return aRank - bRank;
       });
     }
-    return withMatches.sort((a, b) => {
-      const aEligible = eligibleItemsetIds.has(String(a.id)) ? 0 : 1;
-      const bEligible = eligibleItemsetIds.has(String(b.id)) ? 0 : 1;
-      if (aEligible !== bEligible) return aEligible - bEligible;
+
+    // Fallback sorting: if active filter, bubbling up eligible!
+    return [...withMatches].sort((a, b) => {
+      // If sorting normally with filter on, eligible bubbling
+      if (hasAnyFilter) {
+        const getRank = (id: string) => {
+          if (!eligibleItemsetIds) return 0;
+          if (eligibleItemsetIds instanceof Set) {
+            return eligibleItemsetIds.has(String(id)) ? 1 : 0;
+          }
+          if (eligibleItemsetIds.direct?.has(String(id))) return 2;
+          if (eligibleItemsetIds.indirect?.has(String(id))) return 1;
+          return 0;
+        };
+        const aR = getRank(String(a.id));
+        const bR = getRank(String(b.id));
+        if (aR !== bR) return bR - aR;
+      }
       return 0;
     });
-  }, [contextNodeNames, patternMatchedNodesMap, itemsets, eligibleItemsetIds, selectedItemsetId, frozenOrder]);
-
-  const matchedIndices = useMemo(() => {
-    const indices: number[] = [];
-    displayItemsets.forEach((itemset, idx) => {
-      const isSelected = selectedItemsetId === itemset.id;
-      const isEligible = eligibleItemsetIds.has(String(itemset.id));
-      if (isSelected || isEligible) indices.push(idx);
-    });
-    return indices;
-  }, [displayItemsets, selectedItemsetId, eligibleItemsetIds]);
+  }, [itemsets, contextNodeNames, patternMatchedNodesMap, frozenOrder, selectedItemsetIds, eligibleItemsetIds, hasAnyFilter]);
+
+  const matchedIndices: number[] = [];
+  displayItemsets.forEach((itemset, idx) => {
+    const isSelected = selectedItemsetIds.has(String(itemset.id));
+    const isEligible = !eligibleItemsetIds ? false : eligibleItemsetIds instanceof Set ? eligibleItemsetIds.has(String(itemset.id)) : (eligibleItemsetIds.direct?.has(String(itemset.id)) || eligibleItemsetIds.indirect?.has(String(itemset.id)));
+    if (isSelected || isEligible) matchedIndices.push(idx);
+  });
 
   const clampedFocusIdx = matchedIndices.length > 0 ? Math.min(focusIndex, matchedIndices.length - 1) : 0;
   const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
 
   useEffect(() => {
-    if (focusTrigger > 0 && focusedItemIndex >= 0) {
-      itemRefs.current[focusedItemIndex]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    if (focusTrigger > 0 && focusedItemIndex >= 0 && itemRefs.current[focusedItemIndex]) {
+      itemRefs.current[focusedItemIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
     }
-  }, [focusedItemIndex, focusTrigger]);
+  }, [focusTrigger, focusedItemIndex]);
 
   return (
-    <div className="w-[260px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden border-t-2 border-t-blue-500">
-      <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 shrink-0">
+    <div className="w-[300px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden">
+      <div className={cn("px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 shrink-0", DASHBOARD_COLUMN_THEME.pattern.headerColor)}>
         <div className="flex justify-between items-center">
           <span>Pattern</span>
           <div className="flex items-center gap-2">
-            {matchedIndices.length > 1 && (
-              <div className="flex items-center gap-1">
-                <button
-                  type="button"
-                  onClick={onFocusPrev}
-                  disabled={clampedFocusIdx === 0}
-                  className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-                >
-                  <ChevronLeft size={13} />
-                </button>
-                <span className="text-[10px] font-bold text-slate-500 min-w-[28px] text-center">
-                  {clampedFocusIdx + 1}/{matchedIndices.length}
-                </span>
-                <button
-                  type="button"
-                  onClick={onFocusNext}
-                  disabled={clampedFocusIdx === matchedIndices.length - 1}
-                  className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-                >
-                  <ChevronRight size={13} />
-                </button>
-              </div>
-            )}
             <span className="text-slate-400">{displayItemsets.length}</span>
           </div>
         </div>
       </div>
       <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
         {displayItemsets.map((itemset, idx) => {
-          const isSelected = selectedItemsetId === itemset.id;
-          const isEligible = eligibleItemsetIds.has(String(itemset.id));
+          const isSelected = selectedItemsetIds.has(String(itemset.id));
+          const isDirect = !eligibleItemsetIds ? false : eligibleItemsetIds instanceof Set ? eligibleItemsetIds.has(String(itemset.id)) : eligibleItemsetIds.direct?.has(String(itemset.id));
+          const isIndirect = !eligibleItemsetIds ? false : eligibleItemsetIds instanceof Set ? false : eligibleItemsetIds.indirect?.has(String(itemset.id));
+          const isEligible = isDirect || isIndirect;
           const metrics = metricsMap[String(itemset.id)] || { nodeCount: 0, reqCount: 0, procCount: 0, capCount: 0, toolCount: 0 };
           const metricItems = [
             { key: 'node', count: metrics.nodeCount, className: 'bg-slate-500 text-white' },
@@ -628,6 +619,7 @@ function PatternColumn({
               ))}
             </>
           );
+          const isUnrelated = hasAnyFilter && !isEligible && !isSelected;
           return (
             <div
               key={itemset.id}
@@ -635,31 +627,31 @@ function PatternColumn({
                 itemRefs.current[idx] = el;
               }}
               onClick={() => {
-                if (hasAnyFilter && !isEligible && !isSelected) return;
                 const next = isSelected ? null : itemset.id;
                 onSelectItemset(next, displayItemsets.map((entry) => String(entry.id)));
-                if (next !== null) onOpenDrawer(itemset);
               }}
               className={cn(
-                "group bg-white rounded-xl p-3 mb-2 cursor-pointer transition-all border-l-4 border-l-blue-400",
+                "group bg-white rounded-xl p-3 mb-2 cursor-pointer transition-all border-l-4",
+                // 仅在非 direct/selected/unrelated 时保留 column identity 蓝色左条
+                !isSelected && !(hasAnyFilter && isDirect) && !isUnrelated && "border-l-blue-400",
                 isSelected
-                  ? "border border-orange-400 shadow-[0_0_0_1px_rgba(251,146,60,0.7)]"
-                  : isEligible
-                  ? "border border-sky-300 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
-                  : "border border-transparent hover:border-slate-200",
-                hasAnyFilter && !isEligible && !isSelected && "border border-slate-200 bg-slate-50 opacity-45 saturate-50 cursor-not-allowed"
+                  ? "border-l-orange-400 border border-orange-400 shadow-[0_0_0_1px_rgba(251,146,60,0.7)]"
+                  : (hasAnyFilter && isDirect)
+                    ? "border-l-sky-400 border border-sky-300 bg-sky-50/70 shadow-[0_0_0_1px_rgba(125,211,252,0.5)]"
+                    : isUnrelated
+                      ? "border-l-slate-200 border border-slate-200 bg-slate-50 opacity-45 saturate-50"
+                      : "border border-transparent hover:border-slate-200"
               )}
             >
-              <div className="flex items-start gap-2">
-                <div className="min-w-0 flex-1">
-                  <div className="flex flex-wrap gap-1 mt-1.5">
+              <div className="flex flex-col">
+                <div className="flex items-start justify-between">
+                  <div className="flex flex-wrap gap-1 flex-1 mt-1.5">
                     {(itemset.leaf_names || []).map((name: string) => {
                       const isMatched = itemset.matched_nodes.includes(name);
                       return (
                         <span
                           key={name}
                           onClick={(e) => {
-                            if (hasAnyFilter && !isEligible && !isSelected) return;
                             if (!onNodeClick) return;
                             e.stopPropagation();
                             onNodeClick(name);
@@ -669,7 +661,7 @@ function PatternColumn({
                             (isSelected || isEligible) && isMatched
                               ? "bg-blue-100 text-blue-700 ring-1 ring-blue-400 border-blue-200 font-bold"
                               : "bg-slate-50 text-slate-700 border-slate-200",
-                            onNodeClick && !(hasAnyFilter && !isEligible && !isSelected) && "cursor-pointer hover:ring-1 hover:ring-blue-300"
+                            onNodeClick && "cursor-pointer hover:ring-1 hover:ring-blue-300"
                           )}
                         >
                           {name}
@@ -677,12 +669,28 @@ function PatternColumn({
                       );
                     })}
                   </div>
-                  <div className={cn(
-                    "flex items-center gap-1.5 mt-2 transition-opacity duration-150",
-                    isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100"
-                  )}>
-                    {metricDots}
-                  </div>
+                  <button
+                    type="button"
+                    onClick={(e) => {
+                      e.stopPropagation();
+                      onOpenDrawer(itemset);
+                    }}
+                    className={cn(
+                      "p-1.5 ml-2 mt-1 rounded-lg shrink-0 transition-all",
+                      isSelected
+                        ? "bg-orange-100 text-orange-600 hover:bg-orange-200"
+                        : "text-slate-400 hover:bg-slate-100 hover:text-slate-600"
+                    )}
+                    title="查看详情"
+                  >
+                    <FileText size={15} />
+                  </button>
+                </div>
+                <div className={cn(
+                  "flex items-center gap-1.5 mt-2 transition-opacity duration-150",
+                  isSelected ? "opacity-100" : "opacity-0 group-hover:opacity-100"
+                )}>
+                  {metricDots}
                 </div>
               </div>
             </div>
@@ -709,19 +717,39 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
 
   const allCaps = useMemo(() => [...dbData.caps, ...virtualCaps], [dbData.caps, virtualCaps]);
 
-  const [selectedNode, setSelectedNode] = useState<any>(null);
-  const [selectedReqId, setSelectedReqId] = useState<string | null>(null);
-  const [selectedProcId, setSelectedProcId] = useState<string | null>(null);
-  const [selectedCapId, setSelectedCapId] = useState<string | null>(null);
-  const [selectedToolId, setSelectedToolId] = useState<string | null>(null);
-  const [selectedItemsetId, setSelectedItemsetId] = useState<number | null>(null);
+
 
   // 关系列UI状态(原有的 focus/drawer 等)
   const [drawerItem, setDrawerItem] = useState<{ type: string; data: any; nodePostsMap?: Record<string, string[]> } | null>(null);
-  const [treeWideMode, setTreeWideMode] = useState(false);
+  // 内容树宽度:可拖拽调节;"宽/窄" 按钮在 420 / 900 之间切换,drag 用于精确微调
+  const TREE_WIDTH_MIN = 280;
+  const TREE_WIDTH_MAX = 1600;
+  const TREE_WIDTH_PRESET_NARROW = 420;
+  const TREE_WIDTH_PRESET_WIDE = 900;
+  const [treeWidth, setTreeWidth] = useState(TREE_WIDTH_PRESET_NARROW);
+  const treeWideMode = treeWidth >= (TREE_WIDTH_PRESET_NARROW + TREE_WIDTH_PRESET_WIDE) / 2;
+  const beginTreeDrag = (e: React.MouseEvent) => {
+    e.preventDefault();
+    const startX = e.clientX;
+    const startWidth = treeWidth;
+    const onMove = (ev: MouseEvent) => {
+      const next = Math.max(TREE_WIDTH_MIN, Math.min(TREE_WIDTH_MAX, startWidth + (ev.clientX - startX)));
+      setTreeWidth(next);
+    };
+    const onUp = () => {
+      window.removeEventListener('mousemove', onMove);
+      window.removeEventListener('mouseup', onUp);
+      document.body.style.cursor = '';
+      document.body.style.userSelect = '';
+    };
+    window.addEventListener('mousemove', onMove);
+    window.addEventListener('mouseup', onUp);
+    document.body.style.cursor = 'col-resize';
+    document.body.style.userSelect = 'none';
+  };
   const [selectedPostDetail, setSelectedPostDetail] = useState<{ postId: string; post: any } | null>(null);
   const [onlyCoveredFilter, setOnlyCoveredFilter] = useState(false);
-  
+
   const [columnFocusIndex, setColumnFocusIndex] = useState<Record<string, number>>({});
   const [columnFocusTrigger, setColumnFocusTrigger] = useState<Record<string, number>>({});
   const [columnFrozenOrders, setColumnFrozenOrders] = useState<Record<string, string[]>>({});
@@ -733,7 +761,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   // 来自其他页面的跳转
   useEffect(() => {
     if (pendingNode && nameToNodeMap[pendingNode]) {
-      setSelectedNode(nameToNodeMap[pendingNode]);
+      dashFilter.setSingleSelection('tree', String(nameToNodeMap[pendingNode].id));
       setDrawerItem({ type: 'node', data: nameToNodeMap[pendingNode] });
       onPendingConsumed?.();
     }
@@ -925,34 +953,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
 
   const dashFilter = useDashboardFilter(graph);
 
-  // Tree selection injection into graph state
-  useEffect(() => {
-    if (selectedNode) {
-      if (!dashFilter.selections.tree.has(selectedNode.name)) {
-        dashFilter.toggleSelection('tree', selectedNode.name);
-      }
-    } else {
-      dashFilter.selections.tree.forEach(name => {
-        dashFilter.toggleSelection('tree', name);
-      });
-    }
-  }, [selectedNode]);
 
-  // Helper bindings for the legacy UI
-  const getCardState = useCallback((type: 'req' | 'proc' | 'cap' | 'tool' | 'pattern', id: string) => {
-    return dashFilter.getItemState(type, id);
-  }, [dashFilter]);
-
-  const handleEntitySelection = useCallback((type: 'req' | 'proc' | 'cap' | 'tool' | 'pattern', id: string | null) => {
-    // Current UI supports multiselect via modifier keys theoretically, but here let's preserve the toggle capability.
-    // Assuming single select click without modifier toggles just one:
-    if (id !== null) {
-      dashFilter.toggleSelection(type, id);
-    } else {
-      // Meaning "clear selection for this col"
-      dashFilter.selections[type].forEach(existingId => dashFilter.toggleSelection(type, existingId));
-    }
-  }, [dashFilter]);
 
   // Replacement values for what was calculated in loops:
   const hasAnyFilter = dashFilter.hasActiveFilters;
@@ -973,34 +974,43 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     return ids;
   }, [dashFilter.filterResults]);
 
-  const relatedItemsetIds = useMemo(() => {
-    const ids = new Set<string>();
-    Array.from(dashFilter.filterResults?.matched || []).forEach(ref => {
-      if (typeof ref === 'string' && ref.startsWith('pattern:::')) {
-        ids.add(ref.split(':::')[1]);
-      }
-    });
-    return ids;
-  }, [dashFilter.filterResults]);
-
-  const selectedSubtreeLeafNames = undefined;
-  const selectedSubtreeNodeIds = undefined;
-  const relationFilterHighlightLeafNames = undefined;
-  // All graph relationships are now dynamically resolved by `dashFilter` engine.
-
   // The filtered arrays:
-  const filteredData = {
+  const filteredData = useMemo(() => ({
     reqs: dbData.reqs.filter((r: any) => dashFilter.getItemState('req', r.id) !== 'dimmed'),
     procs: dbData.procs.filter(p => dashFilter.getItemState('proc', p.id) !== 'dimmed'),
     caps: allCaps.filter((c: any) => dashFilter.getItemState('cap', c.id) !== 'dimmed'),
     tools: dbData.tools.filter((t: any) => dashFilter.getItemState('tool', t.id) !== 'dimmed'),
-  };
+  }), [dbData, allCaps, dashFilter]);
 
   const eligibleReqIds = useMemo(() => new Set(filteredData.reqs.map((r: any) => r.id)), [filteredData.reqs]);
   const eligibleProcIds = useMemo(() => new Set(filteredData.procs.map((workflow) => workflow.id)), [filteredData.procs]);
   const eligibleCapIds = useMemo(() => new Set(filteredData.caps.map((c: any) => c.id)), [filteredData.caps]);
   const eligibleToolIds = useMemo(() => new Set(filteredData.tools.map((t: any) => t.id)), [filteredData.tools]);
-  
+
+  const relatedItemsetIds = useMemo(() => {
+    const directIds = new Set<string>();
+    const indirectIds = new Set<string>();
+
+    // Pattern 列的直接/间接完全由 graph engine 决定,与其他列保持一致
+    // 无筛选时 matched 是 Set(['ALL']),此处不会 startsWith('pattern:::'),结果为空——Pattern 卡显示默认样式
+    Array.from(dashFilter.filterResults?.directMatched || []).forEach(ref => {
+      if (typeof ref === 'string' && ref.startsWith('pattern:::')) {
+        directIds.add(ref.split(':::')[1]);
+      }
+    });
+    Array.from(dashFilter.filterResults?.indirectMatched || []).forEach(ref => {
+      if (typeof ref === 'string' && ref.startsWith('pattern:::')) {
+        indirectIds.add(ref.split(':::')[1]);
+      }
+    });
+
+    return { direct: directIds, indirect: indirectIds };
+  }, [dashFilter.filterResults]);
+
+  const selectedSubtreeLeafNames = undefined;
+  const selectedSubtreeNodeIds = undefined;
+  const relationFilterHighlightLeafNames = undefined;
+  // All graph relationships are now dynamically resolved by `dashFilter` engine.
   const reqRelationTagsMap = useMemo((): Record<string, Array<{ label: string; tone?: 'pattern' | 'direct' }>> => {
     return {}; // Legacy relation tags (Pattern/Direct) are simplified right now
   }, []);
@@ -1009,7 +1019,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   useEffect(() => {
     setColumnFocusIndex({ req: 0, proc: 0, cap: 0, tool: 0, pattern: 0 });
     setTreeFocusIndex(0);
-  }, [selectedNode]);
+  }, [dashFilter.selections.tree]);
 
   useEffect(() => {
     setTreeFocusIndex(0);
@@ -1327,13 +1337,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
     };
   }, [treeData, dbData.reqs, dbData.tools, totalNodeCount, visibleNodeCount, filteredData.reqs.length, filteredData.caps.length, filteredData.tools.length, filteredData.procs.length, allCaps, collectDirectNodesFromReqs]);
 
-  const getColumnActiveId = (type: string): string | null => (
-    type === 'req' ? (selectedReqId ? `req:${selectedReqId}` : null) :
-    type === 'proc' ? (selectedProcId ? `proc:${selectedProcId}` : null) :
-    type === 'cap' ? (selectedCapId ? `cap:${selectedCapId}` : null) :
-    type === 'tool' ? (selectedToolId ? `tool:${selectedToolId}` : null) :
-    null
-  );
+  const getColumnActiveId = (type: string): string | null => null;
 
 
   const sortedItems = (items: any[], eligibleIds?: Set<string>, frozenOrder?: string[]) => {
@@ -1360,7 +1364,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   const jumpToTreeNodeByName = useCallback((name: string) => {
     if (nameToNodeMap && nameToNodeMap[name]) {
       const node = nameToNodeMap[name];
-      setSelectedNode(node);
+      dashFilter.setSingleSelection('tree', String(node.id));
       setManualFocusedTreeNodeId(node.id);
       setTreeFocusTrigger(prev => prev + 1);
     }
@@ -1368,28 +1372,18 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
 
   const handleSingleClick = (nodeId: string, item: any, currentOrderIds: string[]) => {
     const [type, id] = nodeId.split(':');
-    const isSameReq = type === 'req' && selectedReqId === id;
-    const isSameProc = type === 'proc' && selectedProcId === id;
-    const isSameCap = type === 'cap' && selectedCapId === id;
-    const isSameTool = type === 'tool' && selectedToolId === id;
 
     setManualFocusedTreeNodeId(null);
     setTreeFocusTrigger(prev => prev + 1);
 
-    // Call the actual dashboard filter engine
+    // Call the dashboard filter engine to toggle selection natively
     dashFilter.toggleSelection(type as any, id);
 
-    // Keep cosmetic states updated for legacy components
-    if (type === 'proc') setSelectedProcId(isSameProc ? null : id);
-    if (type === 'req') setSelectedReqId(isSameReq ? null : id);
-    if (type === 'cap') setSelectedCapId(isSameCap ? null : id);
-    if (type === 'tool') setSelectedToolId(isSameTool ? null : id);
-
     // Update cosmetic frozen orders
     setColumnFrozenOrders(prev => {
       const next = { ...prev };
-      const isSame = (type === 'req' && isSameReq) || (type === 'proc' && isSameProc) || (type === 'cap' && isSameCap) || (type === 'tool' && isSameTool);
-      if (isSame) delete next[type];
+      const isSelected = dashFilter.selections[type as any].has(id);
+      if (!isSelected) delete next[type];
       else next[type] = currentOrderIds;
       return next;
     });
@@ -1405,49 +1399,35 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
       const fallbackInputPosts: string[] = [];
 
       (item.source_nodes || []).forEach((sn: any) => {
-        const sourceNodeRef = extractSourceNodeRef(sn);
-        if (!sourceNodeRef || sourceNodeRef === '__abstract__' || sourceNodeRef === '__meta__') return;
-        const nodes = resolveNodeRefToNodes(sourceNodeRef, true);
-        if (nodes.length === 0) return;
+        if (!sn) return;
+        const nodeName = typeof sn === 'object' ? (sn.node_name || sn.name || sn.node) : String(sn);
+        if (!nodeName || nodeName === '__abstract__' || nodeName === '__meta__') return;
 
         const explicitPosts = typeof sn === 'object'
           ? [
-              ...(sn.posts || []),
-              ...(sn.post_ids || []),
-              ...(sn.source_posts || []),
-            ]
+            ...(sn.posts || []),
+            ...(sn.post_ids || []),
+            ...(sn.source_posts || []),
+          ]
           : [];
 
         const candidatePosts = (explicitPosts.length > 0 ? explicitPosts : requirementInputPosts)
           .map((entry: any) => typeof entry === 'string' ? entry : entry?.post_id)
           .filter(Boolean);
 
-        candidatePosts.forEach((pid: string) => {
-          if (pid && !fallbackInputPosts.includes(pid)) fallbackInputPosts.push(pid);
-        });
-
-        nodes.forEach((node: any, nodeIdx: number) => {
-          const nodePostIds: string[] = [];
-          (node?.elements || []).forEach((el: any) => {
-            (el.post_ids || []).forEach((pid: string) => {
-              if (pid && !nodePostIds.includes(pid)) nodePostIds.push(pid);
-            });
-          });
-          const nodePostIdSet = new Set(nodePostIds);
-
-          const postIds: string[] = [];
+        if (candidatePosts.length > 0) {
+          if (!nodePostsMap[nodeName]) {
+            nodePostsMap[nodeName] = [];
+          }
           candidatePosts.forEach((pid: string) => {
-            if (nodePostIdSet.has(pid) && !postIds.includes(pid)) postIds.push(pid);
+            if (pid && !nodePostsMap[nodeName].includes(pid)) {
+              nodePostsMap[nodeName].push(pid);
+            }
+            if (pid && !fallbackInputPosts.includes(pid)) {
+              fallbackInputPosts.push(pid);
+            }
           });
-
-          if (postIds.length > 0) {
-            const displayKeyBase = node.name || sourceNodeRef;
-            const displayKey = nodeIdx === 0 && !nodePostsMap[displayKeyBase]
-              ? displayKeyBase
-              : `${displayKeyBase} #${node.id}`;
-            nodePostsMap[displayKey] = postIds;
-          }
-        });
+        }
       });
 
       const allInputPosts = requirementInputPosts.length > 0 ? requirementInputPosts : fallbackInputPosts;
@@ -1470,10 +1450,10 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
   const displayedTools = dbData.tools;
 
   const columns = [
-    { t: 'req',  l: '业务需求', i: Target,   d: displayedReqs,  eligibleIds: eligibleReqIds,  headerColor: DASHBOARD_COLUMN_THEME.req.headerBorder },
+    { t: 'req', l: '业务需求', i: Target, d: displayedReqs, eligibleIds: eligibleReqIds, headerColor: DASHBOARD_COLUMN_THEME.req.headerBorder },
     { t: 'proc', l: '生产工序', i: ListTree, d: displayedProcs as any[], eligibleIds: eligibleProcIds, headerColor: DASHBOARD_COLUMN_THEME.proc.headerBorder },
-    { t: 'cap',  l: '原子能力', i: Cpu,      d: displayedCaps,  eligibleIds: eligibleCapIds,  headerColor: DASHBOARD_COLUMN_THEME.cap.headerBorder },
-    { t: 'tool', l: '执行工具', i: Wrench,   d: displayedTools, eligibleIds: eligibleToolIds, headerColor: DASHBOARD_COLUMN_THEME.tool.headerBorder },
+    { t: 'cap', l: '原子能力', i: Cpu, d: displayedCaps, eligibleIds: eligibleCapIds, headerColor: DASHBOARD_COLUMN_THEME.cap.headerBorder },
+    { t: 'tool', l: '执行工具', i: Wrench, d: displayedTools, eligibleIds: eligibleToolIds, headerColor: DASHBOARD_COLUMN_THEME.tool.headerBorder },
   ];
 
   const countPatternsByReqIds = useCallback((reqIds: Iterable<string>) => 0, []);
@@ -1489,12 +1469,12 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
       <div className="flex flex-col h-[calc(100vh-64px)] w-full items-center justify-center text-slate-400">
         <div className="flex flex-col items-center gap-4">
           <div className="w-8 h-8 flex items-center justify-center">
-             {!dashboardLoadingText.includes('失败') && (
-               <div className="w-5 h-5 border-[3px] border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
-             )}
-             {dashboardLoadingText.includes('失败') && (
-               <span className="text-xl">❌</span>
-             )}
+            {!dashboardLoadingText.includes('失败') && (
+              <div className="w-5 h-5 border-[3px] border-indigo-200 border-t-indigo-500 rounded-full animate-spin"></div>
+            )}
+            {dashboardLoadingText.includes('失败') && (
+              <span className="text-xl">❌</span>
+            )}
           </div>
           <span className={cn("text-sm tracking-widest", dashboardLoadingText.includes('失败') ? "text-rose-500 max-w-xl text-center leading-relaxed" : "text-slate-400 font-bold")}>{dashboardLoadingText}</span>
         </div>
@@ -1514,6 +1494,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
 
       {/* 工具栏 */}
       <div className="flex items-center gap-2 shrink-0">
+        <VersionSwitcher />
         <button
           type="button"
           onClick={() => setFlowBoardExpanded(v => !v)}
@@ -1521,37 +1502,52 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
         >
           {flowBoardExpanded ? '收起流程图' : '展开流程图'}
         </button>
-        <button
-          onClick={() => setOnlyCoveredFilter(v => !v)}
-          className={cn(
-            "text-xs flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors font-bold",
-            onlyCoveredFilter
-              ? "bg-indigo-100 text-indigo-700 hover:bg-indigo-200"
-              : "bg-slate-200 text-slate-500 hover:bg-slate-300"
-          )}
-        >
-          <span className={cn("w-2 h-2 rounded-full", onlyCoveredFilter ? "bg-indigo-500" : "bg-slate-400")} />
-          只看覆盖需求的数据
-        </button>
-
-        {selectedItemsetId !== null && (
+        {/* 暂时隐藏:"只看覆盖需求的数据" —— 筛选逻辑统一由 graph engine 接管 */}
+        {false && (
           <button
-            type="button"
-            onClick={() => setSelectedItemsetId(null)}
-            className={cn("text-xs font-bold px-3 py-1.5 rounded-lg transition-colors", DASHBOARD_COLUMN_THEME.pattern.chip)}
+            onClick={() => setOnlyCoveredFilter(v => !v)}
+            className={cn(
+              "text-xs flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors font-bold",
+              onlyCoveredFilter
+                ? "bg-indigo-100 text-indigo-700 hover:bg-indigo-200"
+                : "bg-slate-200 text-slate-500 hover:bg-slate-300"
+            )}
           >
-            Pattern #{selectedItemsetId} ×
+            <span className={cn("w-2 h-2 rounded-full", onlyCoveredFilter ? "bg-indigo-500" : "bg-slate-400")} />
+            只看覆盖需求的数据
           </button>
         )}
+
+        {([
+          { type: 'tree', label: '树节点', theme: 'bg-slate-700 text-white hover:bg-slate-600' },
+          { type: 'req', label: '业务需求', theme: DASHBOARD_COLUMN_THEME.req.chip },
+          { type: 'proc', label: '工序', theme: DASHBOARD_COLUMN_THEME.proc.chip },
+          { type: 'cap', label: '能力', theme: DASHBOARD_COLUMN_THEME.cap.chip },
+          { type: 'tool', label: '工具', theme: DASHBOARD_COLUMN_THEME.tool.chip },
+          { type: 'pattern', label: '模式', theme: DASHBOARD_COLUMN_THEME.pattern.chip },
+        ] as const).map(({ type, label, theme }) => {
+          const size = dashFilter.selections[type].size;
+          if (size === 0) return null;
+          return (
+            <button
+              key={`chip-${type}`}
+              type="button"
+              onClick={() => {
+                dashFilter.selections[type].forEach(id => dashFilter.toggleSelection(type, id));
+                setColumnFrozenOrders(prev => { const n = { ...prev }; delete n[type]; return n; });
+                if (type === 'tree') setManualFocusedTreeNodeId(null);
+              }}
+              className={cn("text-xs font-bold px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap", theme)}
+            >
+              已选 {size} 项 {label} ×
+            </button>
+          );
+        })}
+
         {hasAnyFilter && (
           <button
             onClick={() => {
-              setSelectedNode(null);
-              setSelectedProcId(null);
-              setSelectedReqId(null);
-              setSelectedCapId(null);
-              setSelectedToolId(null);
-              setSelectedItemsetId(null);
+              dashFilter.clearAll();
               setManualFocusedTreeNodeId(null);
               setColumnFrozenOrders({});
             }}
@@ -1560,6 +1556,23 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
             <X size={12} /> 清除筛选
           </button>
         )}
+
+        {/* 右对齐:复选说明 + 三态图例 */}
+        <div className="ml-auto flex items-center gap-3 text-xs text-slate-500">
+          <span className="text-slate-400">支持同列复选取交集</span>
+          <div className="flex items-center gap-1.5">
+            <span className="inline-block w-4 h-3 rounded border-l-4 border-l-sky-400 border border-sky-300 bg-sky-50" />
+            <span>直接关联</span>
+          </div>
+          <div className="flex items-center gap-1.5">
+            <span className="inline-block w-4 h-3 rounded border-l-4 border-l-slate-300 border border-slate-200 bg-white" />
+            <span>间接关联</span>
+          </div>
+          <div className="flex items-center gap-1.5">
+            <span className="inline-block w-4 h-3 rounded border-l-4 border-l-slate-200 border border-slate-200 bg-slate-50 opacity-45 saturate-50" />
+            <span>无关联(仍可点)</span>
+          </div>
+        </div>
       </div>
 
       {/* 覆盖率统计 */}
@@ -1580,41 +1593,38 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
           )}
         >
           <div className="flex flex-row gap-4 h-full min-w-max">
-            {/* 内容树 */}
-            <div className={cn("shrink-0 min-h-0", treeWideMode ? "w-[900px]" : "w-[420px]")}>
+            {/* 内容树 —— 宽度可拖拽 */}
+            <div className="shrink-0 min-h-0 relative" style={{ width: treeWidth }}>
               <CategoryTree
                 data={treeData}
                 onSelect={(node) => {
                   setManualFocusedTreeNodeId(null);
-                  const isSameNode = selectedNode && String(selectedNode.id) === String(node.id);
-                  if (isSameNode) {
-                    setSelectedNode(null);
-                    return;
-                  }
-                  setSelectedNode(node);
+                  if (node) dashFilter.toggleSelection('tree', String(node.id));
                 }}
                 onOpenDetail={(node) => {
                   setDrawerItem({ type: 'node', data: node });
                 }}
-                selectedId={selectedNode ? selectedNode.id : null}
+                selectedIds={dashFilter.selections.tree}
                 subtreeHighlightNodeIds={treeNavigableNodeIds}
                 highlightLeafNames={
                   selectedSubtreeLeafNames
                     ? selectedSubtreeLeafNames
                     : relationFilterHighlightLeafNames
-                    ? relationFilterHighlightLeafNames
-                    : onlyCoveredFilter
-                    ? coveredLeafNames
-                    : null
+                      ? relationFilterHighlightLeafNames
+                      : onlyCoveredFilter
+                        ? coveredLeafNames
+                        : null
                 }
-                filterLabel={selectedNode ? selectedNode.name : null}
-                onClearFilter={selectedNode ? () => setSelectedNode(null) : undefined}
+                filterLabel={dashFilter.selections.tree.size > 0 ? Array.from(dashFilter.selections.tree).map(id => idToNodeMap?.[id]?.name || id).join(', ') : null}
+                onClearFilter={dashFilter.selections.tree.size > 0 ? () => {
+                  dashFilter.selections.tree.forEach(name => dashFilter.toggleSelection('tree', name));
+                } : undefined}
                 sourceNodeIds={undefined}
                 patternNodeIds={undefined}
                 nodeMetricsMap={{}}
                 totalNodeCount={totalNodeCount}
                 wideMode={treeWideMode}
-                onToggleWideMode={() => setTreeWideMode(m => !m)}
+                onToggleWideMode={() => setTreeWidth(treeWideMode ? TREE_WIDTH_PRESET_NARROW : TREE_WIDTH_PRESET_WIDE)}
                 treeFocusIndex={treeFocusIndex}
                 treeMatchedCount={treeMatchedCount}
                 focusedTreeNodeId={manualFocusedTreeNodeId ?? focusedTreeNode?.id}
@@ -1622,11 +1632,17 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
                 onTreeFocusPrev={() => setTreeFocusIndex(prev => Math.max(0, prev - 1))}
                 onTreeFocusNext={() => setTreeFocusIndex(prev => Math.min(treeMatchedCount - 1, prev + 1))}
               />
+              {/* 拖拽调节宽度的把手 */}
+              <div
+                onMouseDown={beginTreeDrag}
+                className="absolute top-0 right-0 bottom-0 w-1.5 -mr-0.5 cursor-col-resize bg-transparent hover:bg-sky-300/60 active:bg-sky-400 transition-colors z-20"
+                title="拖拽调整内容树宽度"
+              />
             </div>
 
             {/* 频繁模式列 */}
             <PatternColumn
-              selectedItemsetId={selectedItemsetId}
+              selectedItemsetIds={dashFilter.selections.pattern}
               itemsets={allItemsets}
               eligibleItemsetIds={relatedItemsetIds}
               metricsMap={patternMetricsMap || {}}
@@ -1639,25 +1655,24 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
               focusTrigger={columnFocusTrigger.pattern ?? 0}
               onSelectItemset={(itemsetId, currentOrderIds) => {
                 setManualFocusedTreeNodeId(null);
-                
-                // Toggle dashFilter properly
+                setTreeFocusTrigger(prev => prev + 1);
+
                 if (itemsetId !== null) {
                   dashFilter.toggleSelection('pattern', String(itemsetId));
-                } else if (selectedItemsetId !== null) {
-                  dashFilter.toggleSelection('pattern', String(selectedItemsetId));
+                } else {
+                  // 取消信号:清空 pattern 全部选中
+                  Array.from(dashFilter.selections.pattern).forEach(id =>
+                    dashFilter.toggleSelection('pattern', id)
+                  );
+                  setDrawerItem(null);
                 }
 
-                setSelectedItemsetId(itemsetId);
-                if (itemsetId !== null) {
-                  setTreeFocusTrigger(prev => prev + 1);
-                }
                 setColumnFrozenOrders(prev => {
                   const next = { ...prev };
                   if (itemsetId === null) delete next.pattern;
                   else next.pattern = currentOrderIds;
                   return next;
                 });
-                if (itemsetId === null) setDrawerItem(null);
               }}
               onOpenDrawer={(itemset) => setDrawerItem({ type: 'itemset', data: itemset })}
               onNodeClick={jumpToTreeNodeByName}
@@ -1666,7 +1681,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
                 setColumnFocusTrigger(prev => ({ ...prev, pattern: (prev.pattern ?? 0) + 1 }));
               }}
               onFocusNext={() => {
-                const matchedCount = Array.from(relatedItemsetIds).length + (selectedItemsetId ? (relatedItemsetIds.has(String(selectedItemsetId)) ? 0 : 1) : 0);
+                const matchedCount = Array.from(relatedItemsetIds).length + Array.from(dashFilter.selections.pattern).filter(x => !relatedItemsetIds.has(x)).length;
                 setColumnFocusIndex(prev => ({ ...prev, pattern: Math.min(Math.max(0, matchedCount - 1), (prev.pattern ?? 0) + 1) }));
                 setColumnFocusTrigger(prev => ({ ...prev, pattern: (prev.pattern ?? 0) + 1 }));
               }}
@@ -1674,96 +1689,64 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
 
             {/* 关系列:每列固定宽度 */}
             {columns.map(col => {
-          const activeId = getColumnActiveId(col.t);
-          const orderedItems = sortedItems(col.d, col.eligibleIds, activeId !== null ? columnFrozenOrders[col.t] : undefined);
-
-          // 计算所有可用匹配项的索引(按原始顺序)
-          const matchedIndices: number[] = [];
-          orderedItems.forEach((item: any, idx: number) => {
-            const nodeId = `${col.t}:${item.id}`;
-            const isSelected = activeId === nodeId;
-            const isEligible = col.eligibleIds?.has(item.id) ?? false;
-            if (isSelected || isEligible) {
-              matchedIndices.push(idx);
-            }
-          });
+              const orderedItems = sortedItems(col.d, col.eligibleIds, dashFilter.selections[col.t as any]?.size > 0 ? columnFrozenOrders[col.t] : undefined);
+
+              // 计算所有可用匹配项的索引(按原始顺序)
+              const matchedIndices: number[] = [];
+              orderedItems.forEach((item: any, idx: number) => {
+                const isSelected = dashFilter.selections[col.t as any]?.has(String(item.id)) ?? false;
+                const isEligible = col.eligibleIds?.has(item.id) ?? false;
+                if (isSelected || isEligible) {
+                  matchedIndices.push(idx);
+                }
+              });
 
-          const focusIdx = columnFocusIndex[col.t] ?? 0;
-          const clampedFocusIdx = matchedIndices.length > 0 ? Math.min(focusIdx, matchedIndices.length - 1) : 0;
-          const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
+              const focusIdx = columnFocusIndex[col.t] ?? 0;
+              const clampedFocusIdx = matchedIndices.length > 0 ? Math.min(focusIdx, matchedIndices.length - 1) : 0;
+              const focusedItemIndex = matchedIndices[clampedFocusIdx] ?? -1;
 
-          return (
-          <div key={col.t} className={cn("w-[260px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden", col.headerColor)}>
-            <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 shrink-0">
-              <div className="flex justify-between items-center">
-                <span>{col.l}</span>
-                <div className="flex items-center gap-2">
-                  {matchedIndices.length > 1 && (
-                    <div className="flex items-center gap-1">
-                      <button
-                        type="button"
-                        onClick={() => {
-                          setColumnFocusIndex(prev => ({ ...prev, [col.t]: Math.max(0, (prev[col.t] ?? 0) - 1) }));
-                          setColumnFocusTrigger(prev => ({ ...prev, [col.t]: (prev[col.t] ?? 0) + 1 }));
-                        }}
-                        disabled={clampedFocusIdx === 0}
-                        className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-                      >
-                        <ChevronLeft size={13} />
-                      </button>
-                      <span className="text-[10px] font-bold text-slate-500 min-w-[28px] text-center">
-                        {clampedFocusIdx + 1}/{matchedIndices.length}
-                      </span>
-                      <button
-                        type="button"
-                        onClick={() => {
-                          setColumnFocusIndex(prev => ({ ...prev, [col.t]: Math.min(matchedIndices.length - 1, (prev[col.t] ?? 0) + 1) }));
-                          setColumnFocusTrigger(prev => ({ ...prev, [col.t]: (prev[col.t] ?? 0) + 1 }));
-                        }}
-                        disabled={clampedFocusIdx === matchedIndices.length - 1}
-                        className="p-0.5 rounded text-slate-400 hover:text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
-                      >
-                        <ChevronRight size={13} />
-                      </button>
+              return (
+                <div key={col.t} className={cn("w-[260px] shrink-0 flex flex-col h-full bg-slate-100/50 rounded-2xl border border-slate-200 overflow-hidden", col.headerColor)}>
+                  <div className="px-4 py-3 font-bold text-slate-700 border-b bg-slate-200/50 shrink-0">
+                    <div className="flex justify-between items-center">
+                      <span>{col.l}</span>
+                      <div className="flex items-center gap-2">
+                        <span className="text-slate-400">
+                          {`${col.eligibleIds?.size ?? 0}/${col.d.length}`}
+                        </span>
+                      </div>
                     </div>
-                  )}
-                  <span className="text-slate-400">
-                    {`${col.eligibleIds?.size ?? 0}/${col.d.length}`}
-                  </span>
-                </div>
-              </div>
-            </div>
-            <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
-              {orderedItems.map((item: any, idx: number) => (
-                <RelationCard
-                  key={item.id}
-                  type={col.t}
-                  item={item}
-                  reqPlanBData={reqPlanBData}
-                  metrics={undefined}
-                  dimmed={!col.eligibleIds?.has(item.id)}
-                  activeId={activeId}
-                  shouldScrollIntoView={idx === focusedItemIndex && (columnFocusTrigger[col.t] ?? 0) > 0}
-                  selectedLeafNames={selectedSubtreeLeafNames || undefined}
-                  directMatch={dashFilter.getItemState(col.t as any, item.id) === 'active'}
-                  relationTags={col.t === 'req' ? (reqRelationTagsMap[item.id] || []) : []}
-                  showAllSourceTags={col.t === 'req'}
-                  onSingleClick={(nodeId) => handleSingleClick(nodeId, item, orderedItems.map((entry: any) => String(entry.id)))}
-                  onSourceNodeClick={(name) => {
-                    jumpToTreeNodeByName(name);
-                  }}
-                  onOpenDetail={(item) => handleOpenDetailClick(item, col.t)}
-                />
-              ))}
-              {col.d.length === 0 && (
-                <div className="flex items-center justify-center h-full text-xs text-slate-300 font-bold select-none">
-                  {hasAnyFilter ? '当前筛选下无数据' : '无数据'}
+                  </div>
+                  <div className="flex-1 overflow-y-auto p-3 custom-scrollbar">
+                    {orderedItems.map((item: any, idx: number) => (
+                      <RelationCard
+                        key={item.id}
+                        type={col.t}
+                        item={item}
+                        metrics={undefined}
+                        dimmed={dashFilter.getItemState(col.t as any, item.id) === 'dimmed'}
+                        isSelected={dashFilter.selections[col.t as any]?.has(String(item.id)) ?? false}
+                        shouldScrollIntoView={idx === focusedItemIndex && (columnFocusTrigger[col.t] ?? 0) > 0}
+                        selectedLeafNames={selectedSubtreeLeafNames || undefined}
+                        directMatch={dashFilter.getItemState(col.t as any, item.id) === 'matched'}
+                        relationTags={col.t === 'req' ? (reqRelationTagsMap[item.id] || []) : []}
+                        showAllSourceTags={col.t === 'req'}
+                        onSingleClick={(nodeId) => handleSingleClick(nodeId, item, orderedItems.map((entry: any) => String(entry.id)))}
+                        onSourceNodeClick={(name) => {
+                          jumpToTreeNodeByName(name);
+                        }}
+                        onOpenDetail={(item) => handleOpenDetailClick(item, col.t)}
+                      />
+                    ))}
+                    {col.d.length === 0 && (
+                      <div className="flex items-center justify-center h-full text-xs text-slate-300 font-bold select-none">
+                        {hasAnyFilter ? '当前筛选下无数据' : '无数据'}
+                      </div>
+                    )}
+                  </div>
                 </div>
-              )}
-            </div>
-          </div>
-          );
-        })}
+              );
+            })}
           </div>
         </div>
 
@@ -1786,7 +1769,7 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
                 dbData={{ ...dbData, caps: allCaps }}
                 nodePostsMap={drawerItem.nodePostsMap}
                 onOpenPost={(postId, post) => setSelectedPostDetail({ postId, post })}
-                selectedReqId={selectedReqId}
+
               />
             )
           )}

+ 44 - 4
knowhub/frontend/src/services/api.ts

@@ -1,22 +1,50 @@
 import axios from 'axios';
 
+// ─── 数据版本(tao_dev / dev_dedup / dev_abstract)─────────────────────
+export const SUPPORTED_VERSIONS = ['tao_dev', 'dev_dedup', 'dev_abstract'] as const;
+export type DataVersion = typeof SUPPORTED_VERSIONS[number];
+const VERSION_STORAGE_KEY = 'knowhub_version';
+const DEFAULT_VERSION: DataVersion = 'tao_dev';
+
+export function getActiveVersion(): DataVersion {
+  const v = (typeof localStorage !== 'undefined' ? localStorage.getItem(VERSION_STORAGE_KEY) : null) as DataVersion | null;
+  return (v && (SUPPORTED_VERSIONS as readonly string[]).includes(v)) ? v : DEFAULT_VERSION;
+}
+
+export function setActiveVersion(v: DataVersion) {
+  localStorage.setItem(VERSION_STORAGE_KEY, v);
+  cache.clear();  // 切版本必须清前端缓存,否则旧 key 的 cache 还在
+}
+
 const api = axios.create({
   baseURL: '/api',
   timeout: 60000,
 });
 
+// 每个请求自动带上当前版本(含 batch 等不走 fetchWithCache 的)
+api.interceptors.request.use((config) => {
+  config.headers = config.headers ?? {};
+  (config.headers as any)['X-KnowHub-Version'] = getActiveVersion();
+  return config;
+});
+
 const cache = new Map<string, { data: any; timestamp: number }>();
 const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
 
+function cacheKey(url: string): string {
+  return `${getActiveVersion()}::${url}`;
+}
+
 async function fetchWithCache(url: string, force = false) {
-  if (!force && cache.has(url)) {
-    const cached = cache.get(url)!;
+  const key = cacheKey(url);
+  if (!force && cache.has(key)) {
+    const cached = cache.get(key)!;
     if (Date.now() - cached.timestamp < CACHE_TTL) {
       return cached.data;
     }
   }
   const { data } = await api.get(url);
-  cache.set(url, { data, timestamp: Date.now() });
+  cache.set(key, { data, timestamp: Date.now() });
   return data;
 }
 
@@ -128,7 +156,19 @@ export const getCategoryTree = async () => {
 export const getItemsetsAll = async () => {
   const resp = await fetch('/api/pattern/itemsets?execution_id=56');
   if (!resp.ok) throw new Error('Failed to fetch itemsets');
-  return resp.json();
+  const data = await resp.json();
+  if (data.itemsets) {
+    data.itemsets.forEach((itemset: any) => {
+      if (!itemset.leaf_names && itemset.items) {
+        itemset.leaf_names = itemset.items.map((i: any) => {
+          const path = i.category_path || '';
+          const parts = path.split('>');
+          return parts[parts.length - 1].trim();
+        }).filter(Boolean);
+      }
+    });
+  }
+  return data;
 };
 
 export const getRequirementsPlanB = async () => {

+ 12 - 6
knowhub/knowhub_db/pg_capability_store.py

@@ -12,6 +12,7 @@ from psycopg2.extras import RealDictCursor
 from typing import List, Dict, Optional
 from dotenv import load_dotenv
 from knowhub.knowhub_db.cascade import cascade_delete
+from knowhub.knowhub_db.version_context import version_where
 
 load_dotenv()
 
@@ -169,10 +170,11 @@ class PostgreSQLCapabilityStore:
         """根据 ID 获取原子能力"""
         cursor = self._get_cursor()
         try:
+            vf, vp = version_where()
             cursor.execute(f"""
                 SELECT {_SELECT_FIELDS}
-                FROM capability WHERE id = %s
-            """, (cap_id,))
+                FROM capability WHERE id = %s AND {vf}
+            """, (cap_id, *vp))
             result = cursor.fetchone()
             return self._format_result(result) if result else None
         finally:
@@ -182,14 +184,15 @@ class PostgreSQLCapabilityStore:
         """向量检索原子能力"""
         cursor = self._get_cursor()
         try:
+            vf, vp = version_where()
             cursor.execute(f"""
                 SELECT {_SELECT_FIELDS},
                        1 - (embedding <=> %s::real[]) as score
                 FROM capability
-                WHERE embedding IS NOT NULL
+                WHERE embedding IS NOT NULL AND {vf}
                 ORDER BY embedding <=> %s::real[]
                 LIMIT %s
-            """, (query_embedding, query_embedding, limit))
+            """, (query_embedding, *vp, query_embedding, limit))
             results = cursor.fetchall()
             return [self._format_result(r) for r in results]
         finally:
@@ -199,12 +202,14 @@ class PostgreSQLCapabilityStore:
         """列出原子能力"""
         cursor = self._get_cursor()
         try:
+            vf, vp = version_where()
             cursor.execute(f"""
                 SELECT {_SELECT_FIELDS}
                 FROM capability
+                WHERE {vf}
                 ORDER BY id
                 LIMIT %s OFFSET %s
-            """, (limit, offset))
+            """, (*vp, limit, offset))
             results = cursor.fetchall()
             return [self._format_result(r) for r in results]
         finally:
@@ -253,7 +258,8 @@ class PostgreSQLCapabilityStore:
         """统计原子能力总数"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("SELECT COUNT(*) as count FROM capability")
+            vf, vp = version_where()
+            cursor.execute(f"SELECT COUNT(*) as count FROM capability WHERE {vf}", vp)
             return cursor.fetchone()['count']
         finally:
             cursor.close()

+ 19 - 9
knowhub/knowhub_db/pg_requirement_store.py

@@ -145,12 +145,14 @@ class PostgreSQLRequirementStore:
 
     def get_by_id(self, req_id: str) -> Optional[Dict]:
         """根据 ID 获取需求"""
+        from knowhub.knowhub_db.version_context import req_version_where
         cursor = self._get_cursor()
         try:
+            vf, vp = req_version_where()
             cursor.execute(f"""
                 SELECT {_SELECT_FIELDS}
-                FROM requirement WHERE id = %s
-            """, (req_id,))
+                FROM requirement WHERE id = %s AND {vf}
+            """, (req_id, *vp))
             result = cursor.fetchone()
             return self._format_result(result) if result else None
         finally:
@@ -158,16 +160,18 @@ class PostgreSQLRequirementStore:
 
     def search(self, query_embedding: List[float], limit: int = 10) -> List[Dict]:
         """向量检索需求"""
+        from knowhub.knowhub_db.version_context import req_version_where
         cursor = self._get_cursor()
         try:
+            vf, vp = req_version_where()
             cursor.execute(f"""
                 SELECT {_SELECT_FIELDS},
                        1 - (embedding <=> %s::real[]) as score
                 FROM requirement
-                WHERE embedding IS NOT NULL
+                WHERE embedding IS NOT NULL AND {vf}
                 ORDER BY embedding <=> %s::real[]
                 LIMIT %s
-            """, (query_embedding, query_embedding, limit))
+            """, (query_embedding, *vp, query_embedding, limit))
             results = cursor.fetchall()
             return [self._format_result(r) for r in results]
         finally:
@@ -175,23 +179,26 @@ class PostgreSQLRequirementStore:
 
     def list_all(self, limit: int = 100, offset: int = 0, status: Optional[str] = None) -> List[Dict]:
         """列出需求"""
+        from knowhub.knowhub_db.version_context import req_version_where
         cursor = self._get_cursor()
         try:
+            vf, vp = req_version_where()
             if status:
                 cursor.execute(f"""
                     SELECT {_SELECT_FIELDS}
                     FROM requirement
-                    WHERE status = %s
+                    WHERE status = %s AND {vf}
                     ORDER BY id
                     LIMIT %s OFFSET %s
-                """, (status, limit, offset))
+                """, (status, *vp, limit, offset))
             else:
                 cursor.execute(f"""
                     SELECT {_SELECT_FIELDS}
                     FROM requirement
+                    WHERE {vf}
                     ORDER BY id
                     LIMIT %s OFFSET %s
-                """, (limit, offset))
+                """, (*vp, limit, offset))
             results = cursor.fetchall()
             return [self._format_result(r) for r in results]
         finally:
@@ -304,12 +311,15 @@ class PostgreSQLRequirementStore:
 
     def count(self, status: Optional[str] = None) -> int:
         """统计需求总数"""
+        from knowhub.knowhub_db.version_context import req_version_where
         cursor = self._get_cursor()
         try:
+            vf, vp = req_version_where()
             if status:
-                cursor.execute("SELECT COUNT(*) as count FROM requirement WHERE status = %s", (status,))
+                cursor.execute(f"SELECT COUNT(*) as count FROM requirement WHERE status = %s AND {vf}",
+                               (status, *vp))
             else:
-                cursor.execute("SELECT COUNT(*) as count FROM requirement")
+                cursor.execute(f"SELECT COUNT(*) as count FROM requirement WHERE {vf}", vp)
             return cursor.fetchone()['count']
         finally:
             cursor.close()

+ 10 - 7
knowhub/knowhub_db/pg_resource_store.py

@@ -87,13 +87,15 @@ class PostgreSQLResourceStore:
 
     def get_by_id(self, resource_id: str) -> Optional[Dict]:
         """根据ID获取资源"""
+        from knowhub.knowhub_db.version_context import version_where
         cursor = self._get_cursor()
         try:
-            cursor.execute("""
+            vf, vp = version_where()
+            cursor.execute(f"""
                 SELECT id, title, body, secure_body, content_type, metadata, sort_order,
                        created_at, updated_at, version, images
-                FROM resource WHERE id = %s
-            """, (resource_id,))
+                FROM resource WHERE id = %s AND {vf}
+            """, (resource_id, *vp))
             row = cursor.fetchone()
             if not row:
                 return None
@@ -111,7 +113,8 @@ class PostgreSQLResourceStore:
     def list_resources(self, prefix: Optional[str] = None, content_type: Optional[str] = None,
                        version: Optional[str] = None,
                        limit: int = 100, offset: int = 0) -> List[Dict]:
-        """列出资源。version=None 返回所有版本。"""
+        """列出资源。version 显式传入时用传入值;未传时使用当前 contextvar 版本。"""
+        from knowhub.knowhub_db.version_context import get_version
         cursor = self._get_cursor()
         try:
             conditions = []
@@ -123,9 +126,9 @@ class PostgreSQLResourceStore:
             if content_type:
                 conditions.append("content_type = %s")
                 params.append(content_type)
-            if version is not None:
-                conditions.append("version = %s")
-                params.append(version)
+            effective_version = version if version is not None else get_version()
+            conditions.append("version = %s")
+            params.append(effective_version)
 
             where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
             sql = f"""

+ 17 - 3
knowhub/knowhub_db/pg_store.py

@@ -11,6 +11,7 @@ from psycopg2.extras import RealDictCursor, execute_batch
 from typing import List, Dict, Optional
 from dotenv import load_dotenv
 from knowhub.knowhub_db.cascade import cascade_delete
+from knowhub.knowhub_db.version_context import version_where
 
 load_dotenv()
 
@@ -202,6 +203,15 @@ class PostgreSQLStore:
         else:
             return f"WHERE {rel_where}"
 
+    def _inject_version(self, where_clause: str, params: list) -> str:
+        """向 where_clause 注入 version 过滤(include_v0=True,knowledge 共享 v0 基层)"""
+        vf, vp = version_where(include_v0=True)
+        params.extend(vp)
+        if where_clause.strip():
+            # 已有 WHERE x
+            return f'{where_clause} AND {vf}'
+        return f'WHERE {vf}'
+
     def search(self, query_embedding: List[float], filters: Optional[str] = None, limit: int = 10, relation_filters: Optional[Dict[str, str]] = None) -> List[Dict]:
         """向量检索(使用余弦相似度)"""
         cursor = self._get_cursor()
@@ -209,6 +219,7 @@ class PostgreSQLStore:
             where_clause = self._build_where_clause(filters) if filters else ""
             params = []
             where_clause = self._apply_relation_filters(where_clause, relation_filters, params)
+            where_clause = self._inject_version(where_clause, params)
             sql = f"""
                 SELECT {_SELECT_FIELDS},
                        1 - (task_embedding <=> %s::real[]) as score
@@ -231,6 +242,7 @@ class PostgreSQLStore:
             where_clause = self._build_where_clause(filters) if filters else ""
             params = []
             where_clause = self._apply_relation_filters(where_clause, relation_filters, params)
+            where_clause = self._inject_version(where_clause, params)
             sql = f"""
                 SELECT {_SELECT_FIELDS}
                 FROM knowledge
@@ -249,10 +261,11 @@ class PostgreSQLStore:
         cursor = self._get_cursor()
         try:
             fields = _SELECT_FIELDS_WITH_EMB if include_embedding else _SELECT_FIELDS
+            vf, vp = version_where(include_v0=True)
             cursor.execute(f"""
                 SELECT {fields}
-                FROM knowledge WHERE id = %s
-            """, (knowledge_id,))
+                FROM knowledge WHERE id = %s AND {vf}
+            """, (knowledge_id, *vp))
             result = cursor.fetchone()
             return self._format_result(result) if result else None
         finally:
@@ -395,7 +408,8 @@ class PostgreSQLStore:
         """返回知识总数"""
         cursor = self._get_cursor()
         try:
-            cursor.execute("SELECT COUNT(*) as count FROM knowledge")
+            vf, vp = version_where(include_v0=True)
+            cursor.execute(f"SELECT COUNT(*) as count FROM knowledge WHERE {vf}", vp)
             return cursor.fetchone()['count']
         finally:
             cursor.close()

+ 18 - 10
knowhub/knowhub_db/pg_strategy_store.py

@@ -16,6 +16,7 @@ from psycopg2.extras import RealDictCursor
 from typing import List, Dict, Optional
 from dotenv import load_dotenv
 from knowhub.knowhub_db.cascade import cascade_delete
+from knowhub.knowhub_db.version_context import version_where
 
 load_dotenv()
 
@@ -168,7 +169,9 @@ class PostgreSQLStrategyStore:
     def get_by_id(self, strategy_id: str) -> Optional[Dict]:
         cursor = self._get_cursor()
         try:
-            cursor.execute(f"SELECT {_SELECT_FIELDS} FROM strategy WHERE id = %s", (strategy_id,))
+            vf, vp = version_where()
+            cursor.execute(f"SELECT {_SELECT_FIELDS} FROM strategy WHERE id = %s AND {vf}",
+                           (strategy_id, *vp))
             result = cursor.fetchone()
             return self._format_result(result) if result else None
         finally:
@@ -179,26 +182,27 @@ class PostgreSQLStrategyStore:
         """向量检索 strategy"""
         cursor = self._get_cursor()
         try:
+            vf, vp = version_where()
             if status:
                 sql = f"""
                     SELECT {_SELECT_FIELDS},
                            1 - (embedding <=> %s::real[]) as score
                     FROM strategy
-                    WHERE embedding IS NOT NULL AND status = %s
+                    WHERE embedding IS NOT NULL AND status = %s AND {vf}
                     ORDER BY embedding <=> %s::real[]
                     LIMIT %s
                 """
-                params = (query_embedding, status, query_embedding, limit)
+                params = (query_embedding, status, *vp, query_embedding, limit)
             else:
                 sql = f"""
                     SELECT {_SELECT_FIELDS},
                            1 - (embedding <=> %s::real[]) as score
                     FROM strategy
-                    WHERE embedding IS NOT NULL
+                    WHERE embedding IS NOT NULL AND {vf}
                     ORDER BY embedding <=> %s::real[]
                     LIMIT %s
                 """
-                params = (query_embedding, query_embedding, limit)
+                params = (query_embedding, *vp, query_embedding, limit)
             cursor.execute(sql, params)
             results = cursor.fetchall()
             return [self._format_result(r) for r in results]
@@ -209,19 +213,21 @@ class PostgreSQLStrategyStore:
                  status: Optional[str] = None) -> List[Dict]:
         cursor = self._get_cursor()
         try:
+            vf, vp = version_where()
             if status:
                 cursor.execute(f"""
                     SELECT {_SELECT_FIELDS} FROM strategy
-                    WHERE status = %s
+                    WHERE status = %s AND {vf}
                     ORDER BY id
                     LIMIT %s OFFSET %s
-                """, (status, limit, offset))
+                """, (status, *vp, limit, offset))
             else:
                 cursor.execute(f"""
                     SELECT {_SELECT_FIELDS} FROM strategy
+                    WHERE {vf}
                     ORDER BY id
                     LIMIT %s OFFSET %s
-                """, (limit, offset))
+                """, (*vp, limit, offset))
             results = cursor.fetchall()
             return [self._format_result(r) for r in results]
         finally:
@@ -267,10 +273,12 @@ class PostgreSQLStrategyStore:
     def count(self, status: Optional[str] = None) -> int:
         cursor = self._get_cursor()
         try:
+            vf, vp = version_where()
             if status:
-                cursor.execute("SELECT COUNT(*) as count FROM strategy WHERE status = %s", (status,))
+                cursor.execute(f"SELECT COUNT(*) as count FROM strategy WHERE status = %s AND {vf}",
+                               (status, *vp))
             else:
-                cursor.execute("SELECT COUNT(*) as count FROM strategy")
+                cursor.execute(f"SELECT COUNT(*) as count FROM strategy WHERE {vf}", vp)
             return cursor.fetchone()['count']
         finally:
             cursor.close()

+ 61 - 0
knowhub/knowhub_db/version_context.py

@@ -0,0 +1,61 @@
+"""
+数据版本上下文:通过 ContextVar 传递"当前查询使用哪个版本的数据"。
+
+- 读路径:store 层的 list/search/count/get_by_id 从 ACTIVE_VERSION 读当前版本,加到 WHERE
+- 写路径:insert 时使用业务显式传入的 version(不从 contextvar 推断),避免误写入
+- knowledge 表默认带 'v0'(共享基层),其他表不带
+
+典型调用链:
+  FastAPI middleware 从请求头 X-KnowHub-Version 或 ?version=xxx 读取
+  → set_version('dev_dedup')
+  → store.list_all() 内部自动过滤 WHERE version = 'dev_dedup'
+"""
+from contextvars import ContextVar
+from typing import Tuple, List
+
+DEFAULT_VERSION = 'tao_dev'
+KNOWLEDGE_BASE_VERSION = 'v0'  # knowledge 共享基层,永远包含
+
+ACTIVE_VERSION: ContextVar[str] = ContextVar('knowhub_active_version', default=DEFAULT_VERSION)
+
+
+def get_version() -> str:
+    return ACTIVE_VERSION.get()
+
+
+def set_version(v: str) -> None:
+    ACTIVE_VERSION.set(v)
+
+
+def requirement_version() -> str:
+    """
+    reqs 的映射规则(非对称):
+      - active='tao_dev'  → 'tao_dev'(tao_dev 有自己的 req 行,带 __td 后缀)
+      - active='dev_abstract' / 'dev_dedup' → 'v0'(这两版共享 v0 reqs,从未复制)
+      - 其它 → 'v0'
+    """
+    v = ACTIVE_VERSION.get()
+    return v if v == 'tao_dev' else 'v0'
+
+
+def req_version_where(alias: str = '') -> Tuple[str, List[str]]:
+    col = f'{alias}.version' if alias else 'version'
+    return f'{col} = %s', [requirement_version()]
+
+
+def version_where(alias: str = '', include_v0: bool = False) -> Tuple[str, List[str]]:
+    """
+    构造 WHERE 版本过滤片段。
+
+    Args:
+        alias: 表别名(如 'k' / 'strategy'),空则不加前缀
+        include_v0: 是否把 'v0' 也纳入(knowledge 用)
+
+    Returns:
+        (sql_fragment, params)  — sql_fragment 用 %s 占位,params 给 cursor.execute
+    """
+    v = get_version()
+    col = f'{alias}.version' if alias else 'version'
+    if include_v0 and v != KNOWLEDGE_BASE_VERSION:
+        return f'{col} = ANY(%s)', [[v, KNOWLEDGE_BASE_VERSION]]
+    return f'{col} = %s', [v]

+ 68 - 0
knowhub/scripts/taodev_backfill_missing_tools.py

@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+"""
+补齐 tool 表:为 capability_tool 引用但 tool 表里没有的 tool_id 创建存根行。
+
+只看 tao_dev 视图引用的 tool_id(dev_abstract 已经齐备)。
+新建 tool 行打 version='tao_dev' 标签(与 `capability.version='tao_dev'` 呼应)。
+
+字段策略:
+  id        = 原 tool_id(路径或名字,原样保留)
+  name      = 同 id(因为 tao_dev 的 tool_id 大多已经是人类可读名)
+  version   = 'tao_dev'
+  status    = '未接入'(默认)
+  其他      = 空串 / 空 JSON
+"""
+import json, sys, time
+from pathlib import Path
+import psycopg2.extras
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute("SET statement_timeout = '120s'")
+
+        # 所有 cap_tool 引用 vs tool 表现有
+        cur.execute("""SELECT DISTINCT ct.tool_id FROM capability_tool ct
+                       JOIN capability c ON c.id=ct.capability_id""")
+        refs = {r['tool_id'] for r in cur.fetchall()}
+        cur.execute('SELECT id FROM tool')
+        existing = {r['id'] for r in cur.fetchall()}
+        missing = sorted(refs - existing)
+        print(f'cap_tool 引用独特 tool_id: {len(refs)}', flush=True)
+        print(f'tool 表已有: {len(existing)}', flush=True)
+        print(f'缺失待建: {len(missing)}', flush=True)
+
+        now_ts = int(time.time())
+        inserted = 0
+        for tid in missing:
+            cur.execute("""INSERT INTO tool (id, name, version, introduction, tutorial,
+                                             input, output, updated_time, status)
+                           VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
+                           ON CONFLICT (id) DO NOTHING""",
+                        (tid, tid, 'tao_dev', '', '',
+                         json.dumps(''), json.dumps(''), now_ts, '未接入'))
+            inserted += cur.rowcount or 0
+
+        print(f'\ninserted: {inserted}', flush=True)
+
+        # 验证
+        cur.execute("SELECT COUNT(*) c FROM tool WHERE version='tao_dev'")
+        print(f'tool 表 version=tao_dev 的行: {cur.fetchone()["c"]}', flush=True)
+        cur.execute('SELECT COUNT(*) c FROM tool')
+        print(f'tool 表总行数: {cur.fetchone()["c"]}', flush=True)
+
+        # 残留缺失
+        cur.execute("""SELECT DISTINCT ct.tool_id FROM capability_tool ct
+                       WHERE ct.tool_id NOT IN (SELECT id FROM tool)""")
+        still_miss = [r['tool_id'] for r in cur.fetchall()]
+        print(f'仍缺失: {len(still_miss)}', flush=True)
+    finally:
+        cur.close(); s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 91 - 0
knowhub/scripts/taodev_fill_capability_tool.py

@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+"""
+补丁:为 tao_dev 的 capability 从源 JSON 的 implements 字段构造 capability_tool junction。
+
+策略(与 dev_abstract 现有数据一致):
+  - 把 implements 的 key 原样写入 tool_id(可能是路径 "tools/workflow/comfyui"、
+    下划线名 "ji_meng_add_task"、或人类可读名 "ComfyUI")
+  - value(描述字符串)写入 capability_tool.description
+  - 不做任何 canonical 映射,不改 tool 表
+
+每 folder 的 cap 用 {orig_req_id}::{raw_cap_id or 'NEW-<idx>'} 重建 mapping。
+"""
+import json, sys, time
+from pathlib import Path
+
+import psycopg2.extras
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+OUTPUT = Path('/Users/sunlit/Downloads/output-new')
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute("SET statement_timeout = '120s'")
+        cur.execute("""SELECT pid FROM pg_stat_activity WHERE state='idle in transaction'
+                       AND pid!=pg_backend_pid() AND datname=current_database()""")
+        for r in cur.fetchall():
+            cur.execute('SELECT pg_terminate_backend(%s)', (r['pid'],))
+
+        # v0 req 文本 → orig_req_id 映射
+        cur.execute("SELECT id, description FROM requirement WHERE version='v0'")
+        req_map = {r['description']: r['id'] for r in cur.fetchall()}
+        print(f'v0 req 映射: {len(req_map)}', flush=True)
+
+        # 建立 tao_dev cap 集合(快速校验)
+        cur.execute("SELECT id FROM capability WHERE version='tao_dev'")
+        valid_caps = {r['id'] for r in cur.fetchall()}
+        print(f'tao_dev capability 现有: {len(valid_caps)}', flush=True)
+
+        folders = sorted([f for f in OUTPUT.iterdir() if f.is_dir()])
+        stats = {'inserted': 0, 'skipped_no_cap': 0, 'total_implements': 0}
+
+        for folder in folders:
+            t0 = time.time()
+            cd = json.loads((folder / 'capabilities_extracted.json').read_text(encoding='utf-8'))
+            req_text = cd.get('requirement')
+            orig_req = req_map.get(req_text)
+            if not orig_req:
+                print(f'  [{folder.name}] 无法匹配 req', flush=True); continue
+
+            for idx, c in enumerate(cd.get('extracted_capabilities', [])):
+                if not isinstance(c, dict): continue
+                raw_id = (c.get('id') or '').strip()
+                cap_id = f'{orig_req}::{raw_id}' if raw_id else f'{orig_req}::NEW-{idx}'
+                if cap_id not in valid_caps:
+                    stats['skipped_no_cap'] += 1; continue
+                implements = c.get('implements') or {}
+                if not isinstance(implements, dict): continue
+                for tool_key, desc in implements.items():
+                    stats['total_implements'] += 1
+                    cur.execute("""INSERT INTO capability_tool (capability_id, tool_id, description)
+                                   VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
+                                (cap_id, tool_key, str(desc) if desc is not None else ''))
+                    stats['inserted'] += cur.rowcount or 0
+
+            print(f'  [{folder.name}] {orig_req}: {time.time()-t0:.1f}s', flush=True)
+
+        print('\n=== cap_tool 补丁统计 ===', flush=True)
+        for k, v in stats.items():
+            print(f'  {k}: {v}', flush=True)
+
+        cur.execute("""SELECT COUNT(*) c FROM capability_tool ct
+                       JOIN capability c ON c.id=ct.capability_id
+                       WHERE c.version='tao_dev'""")
+        print(f'  tao_dev cap_tool 总计: {cur.fetchone()["c"]}', flush=True)
+
+        # 独特 tool_key 数量
+        cur.execute("""SELECT COUNT(DISTINCT ct.tool_id) c FROM capability_tool ct
+                       JOIN capability c ON c.id=ct.capability_id
+                       WHERE c.version='tao_dev'""")
+        print(f'  tao_dev 独特 tool_key: {cur.fetchone()["c"]}', flush=True)
+    finally:
+        cur.close(); s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 259 - 0
knowhub/scripts/taodev_ingest.py

@@ -0,0 +1,259 @@
+#!/usr/bin/env python3
+"""
+tao_dev 版本:把 /Users/sunlit/Downloads/output-new 下 99 folders 原始数据全量入库。
+
+原则:不做去重。CAP-001 在多个 folder 出现 → 各自生成独立的 cap 行。
+
+关键细节(修订版):
+  - LLM 输出里 is_new=True 的 cap 往往无 id / id 为空 → 用
+    `{req_id}::NEW-{idx}` 合成 ID 入库,不能丢数据。
+  - strategy.workflow_outline.capabilities[] 引用 cap 时有三种情况:
+      a) 有 id → 按 id 查 folder_cap_ids
+      b) 无 id 但有 name → 按 name 查 folder_cap_names
+      c) 两者都无 → 跳过并计数
+  - case_references 用中/西文冒号分隔均兼容。
+
+ID 方案:
+  requirement: {orig_req_id}__td
+  capability : {orig_req_id}::{raw_cap_id or 'NEW-<idx>'}
+  strategy   : strategy-taodev-{orig_req_id}-{idx}
+  resource   : resource/taodev/{orig_req_id}/{platform}/{case_id}
+
+幂等:所有 INSERT 带 ON CONFLICT DO NOTHING;重跑不重复。
+"""
+import json
+import re
+import sys
+import time
+from pathlib import Path
+
+import psycopg2.extras
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+OUTPUT = Path('/Users/sunlit/Downloads/output-new')
+VERSION = 'tao_dev'
+CASE_REF_RE = re.compile(r'^([a-z]+)/(case_\w+)[::\s]')
+
+
+def parse_case_ref(ref: str):
+    if not isinstance(ref, str):
+        return None, None
+    m = CASE_REF_RE.match(ref.strip())
+    if m:
+        return m.group(1), m.group(2)
+    return None, None
+
+
+def norm_name(n):
+    return (n or '').strip()
+
+
+def main():
+    wipe_first = '--wipe' in sys.argv
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute("SET statement_timeout = '300s'")
+        cur.execute("""SELECT pid FROM pg_stat_activity WHERE state='idle in transaction'
+                       AND pid!=pg_backend_pid() AND datname=current_database()""")
+        for r in cur.fetchall():
+            cur.execute('SELECT pg_terminate_backend(%s)', (r['pid'],))
+
+        if wipe_first:
+            print('⚠  --wipe: 清空现有 tao_dev 数据', flush=True)
+            # 清 junction(按 version 过滤父表)
+            for j, parent, fk in [
+                ('strategy_capability', 'strategy', 'strategy_id'),
+                ('strategy_resource',   'strategy', 'strategy_id'),
+                ('requirement_strategy', 'strategy', 'strategy_id'),
+                ('requirement_capability', 'capability', 'capability_id'),
+                ('requirement_resource', 'resource', 'resource_id'),
+            ]:
+                cur.execute(f"""DELETE FROM {j} WHERE {fk} IN
+                                (SELECT id FROM {parent} WHERE version=%s)""", (VERSION,))
+                print(f'  cleared {j}: {cur.rowcount}', flush=True)
+            for t in ['strategy', 'capability', 'resource', 'requirement']:
+                cur.execute(f'DELETE FROM {t} WHERE version=%s', (VERSION,))
+                print(f'  cleared {t}: {cur.rowcount}', flush=True)
+
+        cur.execute('SELECT id, description, source_nodes, status, match_result FROM requirement WHERE version=%s', ('v0',))
+        req_map = {r['description']: r for r in cur.fetchall()}
+        print(f'v0 req 映射: {len(req_map)}', flush=True)
+
+        folders = sorted([f for f in OUTPUT.iterdir() if f.is_dir()])
+        print(f'folders: {len(folders)}', flush=True)
+
+        stats = {'req': 0, 'cap': 0, 'cap_synth_id': 0, 'strat': 0, 'res': 0,
+                 'req_cap': 0, 'req_strat': 0, 'req_res': 0,
+                 'strat_cap_by_id': 0, 'strat_cap_by_name': 0, 'strat_cap_skip': 0,
+                 'strat_res': 0, 'strat_res_skip': 0}
+
+        for folder in folders:
+            t0 = time.time()
+            sd = json.loads((folder / 'strategy.json').read_text(encoding='utf-8'))
+            cd = json.loads((folder / 'capabilities_extracted.json').read_text(encoding='utf-8'))
+            req_text = sd.get('requirement') or cd.get('requirement')
+            r0 = req_map.get(req_text)
+            if not r0:
+                print(f'  [{folder.name}] 无法匹配 req,跳过', flush=True); continue
+            orig_req = r0['id']
+            new_req_id = f'{orig_req}__td'
+
+            # ── 1. requirement ───────────────────────────────
+            cur.execute("""INSERT INTO requirement (id, description, source_nodes, status,
+                                                     match_result, version)
+                           VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT (id) DO NOTHING""",
+                        (new_req_id, r0['description'],
+                         psycopg2.extras.Json(r0['source_nodes']) if r0['source_nodes'] is not None else None,
+                         r0['status'], r0['match_result'], VERSION))
+            stats['req'] += cur.rowcount or 0
+
+            # ── 2. capabilities(含无 id 的新能力)─────────────
+            folder_cap_by_id = {}
+            folder_cap_by_name = {}
+            for idx, c in enumerate(cd.get('extracted_capabilities', [])):
+                if not isinstance(c, dict): continue
+                raw_id = c.get('id') or ''
+                if raw_id.strip():
+                    cap_id = f'{orig_req}::{raw_id}'
+                else:
+                    cap_id = f'{orig_req}::NEW-{idx}'
+                    stats['cap_synth_id'] += 1
+                nm = norm_name(c.get('name'))
+                if nm: folder_cap_by_name[nm] = cap_id
+                if raw_id.strip(): folder_cap_by_id[raw_id] = cap_id
+
+                effects = c.get('effects')
+                effects_json = psycopg2.extras.Json(
+                    {'items': effects} if isinstance(effects, list) else effects
+                ) if effects is not None else None
+                cur.execute("""INSERT INTO capability (id, name, criterion, description, effects, version)
+                               VALUES (%s,%s,%s,%s,%s,%s)
+                               ON CONFLICT (id) DO NOTHING""",
+                            (cap_id, nm, c.get('criterion', ''), c.get('description', ''),
+                             effects_json, VERSION))
+                stats['cap'] += cur.rowcount or 0
+                cur.execute("""INSERT INTO requirement_capability (requirement_id, capability_id)
+                               VALUES (%s,%s) ON CONFLICT DO NOTHING""",
+                            (new_req_id, cap_id))
+                stats['req_cap'] += cur.rowcount or 0
+
+            # ── 3. resources(raw_cases)──────────────────────
+            folder_res_ids = {}
+            rc_dir = folder / 'raw_cases'
+            if rc_dir.exists():
+                for cf in sorted(rc_dir.iterdir()):
+                    if cf.suffix != '.json': continue
+                    try:
+                        cj = json.loads(cf.read_text(encoding='utf-8'))
+                    except Exception:
+                        continue
+                    for case in cj.get('cases', []):
+                        platform = case.get('platform', '')
+                        cid = case.get('id', '')
+                        if not platform or not cid: continue
+                        res_id = f'resource/taodev/{orig_req}/{platform}/{cid}'
+                        folder_res_ids[(platform, cid)] = res_id
+                        metadata = {
+                            'source_url': case.get('source_url'),
+                            'metrics': case.get('metrics'),
+                            'user_feedback': case.get('user_feedback'),
+                            'input_details': case.get('input_details'),
+                            'output_details': case.get('output_details'),
+                            'platform': platform,
+                            'original_case_id': cid,
+                        }
+                        now_ts = int(time.time())
+                        cur.execute("""INSERT INTO resource (id, title, body, secure_body, content_type,
+                                                             metadata, sort_order, submitted_by, created_at,
+                                                             updated_at, images, version)
+                                       VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+                                       ON CONFLICT (id) DO NOTHING""",
+                                    (res_id, case.get('title', ''),
+                                     case.get('workflow_process', ''),
+                                     None, 'research_case',
+                                     psycopg2.extras.Json(metadata), 0, None,
+                                     now_ts, now_ts,
+                                     psycopg2.extras.Json(case.get('images') or []),
+                                     VERSION))
+                        stats['res'] += cur.rowcount or 0
+                        cur.execute("""INSERT INTO requirement_resource (requirement_id, resource_id)
+                                       VALUES (%s,%s) ON CONFLICT DO NOTHING""",
+                                    (new_req_id, res_id))
+                        stats['req_res'] += cur.rowcount or 0
+
+            # ── 4. strategies ─────────────────────────────────
+            for idx, st in enumerate(sd.get('strategies', [])):
+                if not isinstance(st, dict): continue
+                strat_id = f'strategy-taodev-{orig_req}-{idx}'
+                is_sel = bool(st.get('is_selected'))
+                body = {
+                    'source': st.get('source'),
+                    'workflow_outline': st.get('workflow_outline', []),
+                    'original_strategy_name': st.get('name', ''),
+                }
+                now_ts = int(time.time())
+                cur.execute("""INSERT INTO strategy (id, name, description, body, status,
+                                                     created_at, updated_at, version)
+                               VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
+                               ON CONFLICT (id) DO NOTHING""",
+                            (strat_id, (st.get('name', '') or '')[:250],
+                             st.get('source', ''),
+                             psycopg2.extras.Json(body),
+                             'approved', now_ts, now_ts, VERSION))
+                stats['strat'] += cur.rowcount or 0
+                cur.execute("""INSERT INTO requirement_strategy
+                               (requirement_id, strategy_id, is_selected)
+                               VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
+                            (new_req_id, strat_id, is_sel))
+                stats['req_strat'] += cur.rowcount or 0
+
+                for ph in st.get('workflow_outline', []) or []:
+                    if not isinstance(ph, dict): continue
+                    for c in ph.get('capabilities', []) or []:
+                        if not isinstance(c, dict): continue
+                        raw_id = (c.get('id') or '').strip()
+                        nm = norm_name(c.get('name'))
+                        # 先 id 查,再 name 查
+                        mapped = folder_cap_by_id.get(raw_id) if raw_id else None
+                        source_kind = 'by_id'
+                        if not mapped and nm:
+                            mapped = folder_cap_by_name.get(nm)
+                            source_kind = 'by_name'
+                        if not mapped:
+                            stats['strat_cap_skip'] += 1; continue
+                        cur.execute("""INSERT INTO strategy_capability (strategy_id, capability_id)
+                                       VALUES (%s,%s) ON CONFLICT DO NOTHING""",
+                                    (strat_id, mapped))
+                        stats[f'strat_cap_{source_kind}'] += cur.rowcount or 0
+
+                        for ref in c.get('case_references', []) or []:
+                            platform, case_id = parse_case_ref(ref)
+                            if not platform or not case_id:
+                                stats['strat_res_skip'] += 1; continue
+                            res_id = folder_res_ids.get((platform, case_id))
+                            if not res_id:
+                                stats['strat_res_skip'] += 1; continue
+                            cur.execute("""INSERT INTO strategy_resource (strategy_id, resource_id)
+                                           VALUES (%s,%s) ON CONFLICT DO NOTHING""",
+                                        (strat_id, res_id))
+                            stats['strat_res'] += cur.rowcount or 0
+
+            print(f'  [{folder.name}] {orig_req}: {time.time()-t0:.1f}s', flush=True)
+
+        print('\n=== 入库统计 ===', flush=True)
+        for k, v in stats.items():
+            print(f'  {k}: {v}', flush=True)
+
+        print('\n=== tao_dev 版本分布 ===', flush=True)
+        for tbl in ['requirement', 'capability', 'strategy', 'resource']:
+            cur.execute(f'SELECT COUNT(*) c FROM {tbl} WHERE version=%s', (VERSION,))
+            print(f'  {tbl}: {cur.fetchone()["c"]}', flush=True)
+    finally:
+        cur.close(); s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 82 - 0
knowhub/scripts/taodev_probe.py

@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""
+探查 output-new 文件夹:
+ 1. 99 folders 是否一一对应到现有 v0 的 REQ_xxx(按 requirement 文本匹配)
+ 2. 统计每 folder 的 cap / strat / resource 数量
+ 3. 检测有无异常(req 文本缺失、JSON schema 不规范等)
+"""
+import json, sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+OUTPUT = Path('/Users/sunlit/Downloads/output-new')
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute('SELECT id, description FROM requirement')
+        v0_reqs = {r['description']: r['id'] for r in cur.fetchall()}
+        print(f'v0 reqs in DB: {len(v0_reqs)}', flush=True)
+    finally:
+        cur.close(); s.close()
+
+    folders = sorted([f for f in OUTPUT.iterdir() if f.is_dir()])
+    print(f'tao_dev folders: {len(folders)}', flush=True)
+
+    matched, unmatched = 0, []
+    cap_total = strat_total = res_total = 0
+    cap_is_new_true = 0
+    strat_cap_links = 0
+    for folder in folders:
+        sp = folder / 'strategy.json'
+        cp = folder / 'capabilities_extracted.json'
+        if not sp.exists() or not cp.exists():
+            unmatched.append((folder.name, 'missing file')); continue
+        try:
+            sd = json.loads(sp.read_text(encoding='utf-8'))
+            cd = json.loads(cp.read_text(encoding='utf-8'))
+        except Exception as e:
+            unmatched.append((folder.name, f'parse: {e}')); continue
+
+        req_text = sd.get('requirement') or cd.get('requirement')
+        orig_req_id = v0_reqs.get(req_text)
+        if not orig_req_id:
+            unmatched.append((folder.name, f'no match: {req_text[:40]!r}...')); continue
+        matched += 1
+
+        caps = cd.get('extracted_capabilities', [])
+        cap_total += len(caps)
+        cap_is_new_true += sum(1 for c in caps if c.get('is_new'))
+
+        strats = sd.get('strategies', [])
+        strat_total += len(strats)
+        for st in strats:
+            for ph in st.get('workflow_outline', []) or []:
+                if isinstance(ph, dict):
+                    strat_cap_links += len(ph.get('capabilities', []) or [])
+
+        rc = folder / 'raw_cases'
+        if rc.exists():
+            for cf in rc.iterdir():
+                try:
+                    cj = json.loads(cf.read_text(encoding='utf-8'))
+                    res_total += len(cj.get('cases', []))
+                except Exception:
+                    pass
+
+    print(f'\n匹配到 v0 req: {matched}/{len(folders)}', flush=True)
+    print(f'未匹配: {len(unmatched)}', flush=True)
+    for n, why in unmatched[:10]:
+        print(f'  {n}: {why}', flush=True)
+    print(f'\n总计:', flush=True)
+    print(f'  extracted_capabilities: {cap_total}  (其中 is_new=true: {cap_is_new_true})', flush=True)
+    print(f'  strategies: {strat_total}  (平均 {strat_total/max(matched,1):.1f}/req)', flush=True)
+    print(f'  strategy→cap 链接: {strat_cap_links}', flush=True)
+    print(f'  raw_cases 案例: {res_total}  (平均 {res_total/max(matched,1):.1f}/req)', flush=True)
+
+
+if __name__ == '__main__':
+    main()

+ 84 - 0
knowhub/scripts/version_investigate.py

@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""
+Step 1: 查看当前 DB version 分布,为 dev_dedup / dev_abstract 版本化做准备。
+
+不改数据,只 SELECT。
+"""
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+
+def show(cur, title, sql, params=None):
+    print(f'\n=== {title} ===', flush=True)
+    cur.execute(sql, params or ())
+    rows = cur.fetchall()
+    if not rows:
+        print('  (无数据)', flush=True); return
+    cols = list(rows[0].keys())
+    print('  ' + ' | '.join(cols), flush=True)
+    for r in rows:
+        print('  ' + ' | '.join(str(r[c]) for c in cols), flush=True)
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute("SET statement_timeout = '30s'")
+
+        # 主表版本分布
+        for tbl in ['capability', 'strategy', 'knowledge', 'resource']:
+            show(cur, f'{tbl} version 分布',
+                 f'SELECT version, COUNT(*) n FROM {tbl} GROUP BY version ORDER BY n DESC')
+
+        # requirement 是否有 version 列
+        show(cur, 'requirement 表列结构',
+             """SELECT column_name, data_type FROM information_schema.columns
+                WHERE table_name='requirement' ORDER BY ordinal_position""")
+
+        # junction 表行数
+        for tbl in ['requirement_strategy', 'requirement_knowledge', 'requirement_capability',
+                    'requirement_resource', 'strategy_capability', 'strategy_resource',
+                    'strategy_knowledge', 'knowledge_capability', 'knowledge_resource']:
+            cur.execute(f'SELECT COUNT(*) c FROM {tbl}')
+            print(f'  {tbl}: {cur.fetchone()["c"]}', flush=True)
+
+        # 备份表
+        print('\n=== bk_20260422_* 备份表 ===', flush=True)
+        cur.execute("""SELECT table_name FROM information_schema.tables
+                       WHERE table_name LIKE 'bk_20260422_%' ORDER BY table_name""")
+        for r in cur.fetchall():
+            cur.execute(f'SELECT COUNT(*) c FROM {r["table_name"]}')
+            print(f'  {r["table_name"]}: {cur.fetchone()["c"]}', flush=True)
+
+        # 备份 strategy 里的字段(确认有 version 列以便恢复)
+        show(cur, 'bk_20260422_strategy 列结构',
+             """SELECT column_name, data_type FROM information_schema.columns
+                WHERE table_name='bk_20260422_strategy' ORDER BY ordinal_position""")
+
+        # 现在 strategy 表里 version 为 howard_strategy_instance 的有多少
+        show(cur, 'strategy version=howard_strategy_instance 的条目示例',
+             """SELECT id, name, version FROM strategy
+                WHERE version='howard_strategy_instance' LIMIT 3""")
+
+        # knowledge v0 示例
+        show(cur, 'knowledge version=v0 示例',
+             """SELECT id, version FROM knowledge WHERE version='v0' LIMIT 3""")
+
+        # capability 现在的 version(可能是 NULL 或某默认值)
+        show(cur, 'capability sample',
+             """SELECT id, name, version FROM capability LIMIT 3""")
+
+        # resource 现在的 version
+        show(cur, 'resource sample',
+             """SELECT id, version FROM resource LIMIT 3""")
+
+    finally:
+        cur.close(); s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 45 - 0
knowhub/scripts/version_investigate2.py

@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""补查:requirement version 分布 + bk_20260422_strategy 的 version 值 + capability.tags 结构"""
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+
+def show(cur, title, sql):
+    print(f'\n=== {title} ===', flush=True)
+    cur.execute(sql)
+    rows = cur.fetchall()
+    if not rows: print('  (空)', flush=True); return
+    cols = list(rows[0].keys())
+    print('  ' + ' | '.join(cols), flush=True)
+    for r in rows:
+        print('  ' + ' | '.join(str(r[c])[:80] for c in cols), flush=True)
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute("SET statement_timeout = '30s'")
+        show(cur, 'requirement version 分布',
+             "SELECT version, COUNT(*) n FROM requirement GROUP BY version ORDER BY n DESC")
+        show(cur, 'bk_20260422_strategy 的 version 分布',
+             "SELECT version, COUNT(*) n FROM bk_20260422_strategy GROUP BY version")
+        show(cur, 'bk_20260422_strategy_capability 的 cap_id 前 5',
+             "SELECT DISTINCT capability_id FROM bk_20260422_strategy_capability LIMIT 5")
+        show(cur, 'capability 列结构',
+             """SELECT column_name, data_type FROM information_schema.columns
+                WHERE table_name='capability' ORDER BY ordinal_position""")
+        show(cur, 'resource 列结构',
+             """SELECT column_name, data_type FROM information_schema.columns
+                WHERE table_name='resource' ORDER BY ordinal_position""")
+        show(cur, 'strategy 列结构',
+             """SELECT column_name, data_type FROM information_schema.columns
+                WHERE table_name='strategy' ORDER BY ordinal_position""")
+    finally:
+        cur.close(); s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 58 - 0
knowhub/scripts/version_step1_rename.py

@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+"""
+Step 1: Rename 现有 version 标签到新命名方案。
+
+- capability.version: 'howard_dedup' → 'dev_abstract'  (332)
+- resource.version:   'howard_dedup' → 'dev_abstract'  (2539)
+- strategy.version:   'howard_strategy_instance' → 'dev_abstract'  (26)
+- knowledge.version:  'howard_strategy_instance' → 'dev_abstract'  (193); v0 保持不变
+
+requirement 不动(跨版本共享)。
+"""
+import sys, time
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+
+RENAMES = [
+    ('capability', 'howard_dedup', 'dev_abstract'),
+    ('resource',   'howard_dedup', 'dev_abstract'),
+    ('strategy',   'howard_strategy_instance', 'dev_abstract'),
+    ('knowledge',  'howard_strategy_instance', 'dev_abstract'),
+]
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute("SET statement_timeout = '60s'")
+        # kill idle-in-tx(防卡锁)
+        cur.execute("""SELECT pid FROM pg_stat_activity WHERE state='idle in transaction'
+                       AND pid!=pg_backend_pid() AND datname=current_database()""")
+        for r in cur.fetchall():
+            pid = r['pid']
+            print(f'kill idle-in-tx pid={pid}', flush=True)
+            cur.execute("SELECT pg_terminate_backend(%s)", (pid,))
+
+        for tbl, old, new in RENAMES:
+            t0 = time.time()
+            cur.execute(f"SELECT COUNT(*) c FROM {tbl} WHERE version=%s", (old,))
+            before = cur.fetchone()['c']
+            cur.execute(f"UPDATE {tbl} SET version=%s WHERE version=%s", (new, old))
+            cur.execute(f"SELECT COUNT(*) c FROM {tbl} WHERE version=%s", (new,))
+            after = cur.fetchone()['c']
+            print(f'[{time.time()-t0:.1f}s] {tbl}: {old}→{new}  before={before}  after(new)={after}', flush=True)
+
+        print('\n=== 全表 version 分布(验证)===', flush=True)
+        for tbl in ['capability', 'resource', 'strategy', 'knowledge', 'requirement']:
+            cur.execute(f"SELECT version, COUNT(*) n FROM {tbl} GROUP BY version ORDER BY n DESC")
+            parts = [f'{r["version"]}={r["n"]}' for r in cur.fetchall()]
+            print(f'  {tbl}: {", ".join(parts)}', flush=True)
+    finally:
+        cur.close(); s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 214 - 0
knowhub/scripts/version_step2_duplicate_dev_dedup.py

@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+"""
+Step 2: 为 dev_dedup 版本做严格冗余复制 + 从 bk_20260422_* 恢复原具体 strategy 数据。
+
+ID 命名规则:
+  - 复制的 capability / resource 行 ID 加 '__dd' 后缀
+  - strategy 从备份恢复,沿用原 ID(已从主表 DELETE,无冲突)
+
+执行顺序(FK 依赖):
+  1. capability  (332 → dev_dedup 新 ID)
+  2. resource    (2539 → dev_dedup 新 ID)
+  3. requirement_capability  (1106 → remap cap_id 到 dev_dedup)
+  4. requirement_resource    (2736 → remap resource_id 到 dev_dedup)
+  5. strategy    (99 from bk,version 改 dev_dedup)
+  6. requirement_strategy    (99 from bk,is_selected 默认 TRUE,coverage 空)
+  7. strategy_capability     (703 from bk,remap cap_id)
+  8. strategy_resource       (2736 from bk,remap resource_id)
+"""
+import sys, time
+from pathlib import Path
+
+import psycopg2.extras
+
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+SUFFIX = '__dd'
+
+
+def remap(old_id: str) -> str:
+    return f'{old_id}{SUFFIX}'
+
+
+def t(label, fn):
+    t0 = time.time()
+    r = fn()
+    print(f'  [{time.time()-t0:.1f}s] {label}', flush=True)
+    return r
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute("SET statement_timeout = '120s'")
+        cur.execute("""SELECT pid FROM pg_stat_activity WHERE state='idle in transaction'
+                       AND pid!=pg_backend_pid() AND datname=current_database()""")
+        for r in cur.fetchall():
+            cur.execute("SELECT pg_terminate_backend(%s)", (r['pid'],))
+
+        # ===== 1. capability 复制 =====
+        print('\n[1] 复制 capability 给 dev_dedup', flush=True)
+        cur.execute("""SELECT id, name, criterion, description, effects
+                       FROM capability WHERE version='dev_abstract'""")
+        caps = cur.fetchall()
+        print(f'  源 cap 行数: {len(caps)}', flush=True)
+        inserted = 0
+        for c in caps:
+            new_id = remap(c['id'])
+            cur.execute("""INSERT INTO capability (id, name, criterion, description, effects, version)
+                           VALUES (%s, %s, %s, %s, %s, 'dev_dedup') ON CONFLICT (id) DO NOTHING""",
+                        (new_id, c['name'], c['criterion'], c['description'],
+                         psycopg2.extras.Json(c['effects']) if c['effects'] is not None else None))
+            inserted += cur.rowcount or 0
+        print(f'  inserted: {inserted}', flush=True)
+
+        # ===== 2. resource 复制 =====
+        print('\n[2] 复制 resource 给 dev_dedup', flush=True)
+        cur.execute("""SELECT id, title, body, secure_body, content_type, metadata,
+                              sort_order, submitted_by, created_at, updated_at, images
+                       FROM resource WHERE version='dev_abstract'""")
+        ress = cur.fetchall()
+        print(f'  源 resource 行数: {len(ress)}', flush=True)
+        inserted = 0
+        for r in ress:
+            new_id = remap(r['id'])
+            cur.execute("""INSERT INTO resource (id, title, body, secure_body, content_type,
+                                                 metadata, sort_order, submitted_by, created_at,
+                                                 updated_at, images, version)
+                           VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'dev_dedup')
+                           ON CONFLICT (id) DO NOTHING""",
+                        (new_id, r['title'], r['body'], r['secure_body'], r['content_type'],
+                         psycopg2.extras.Json(r['metadata']) if r['metadata'] is not None else None,
+                         r['sort_order'], r['submitted_by'], r['created_at'], r['updated_at'],
+                         psycopg2.extras.Json(r['images']) if r['images'] is not None else None))
+            inserted += cur.rowcount or 0
+        print(f'  inserted: {inserted}', flush=True)
+
+        # ===== 3. requirement_capability 复制(remap cap_id)=====
+        print('\n[3] 复制 requirement_capability(remap cap_id)', flush=True)
+        cur.execute("""SELECT rc.requirement_id, rc.capability_id
+                       FROM requirement_capability rc
+                       JOIN capability c ON c.id=rc.capability_id
+                       WHERE c.version='dev_abstract'""")
+        rc_rows = cur.fetchall()
+        print(f'  源 req_cap 行数: {len(rc_rows)}', flush=True)
+        inserted = 0
+        for row in rc_rows:
+            cur.execute("""INSERT INTO requirement_capability (requirement_id, capability_id)
+                           VALUES (%s, %s) ON CONFLICT DO NOTHING""",
+                        (row['requirement_id'], remap(row['capability_id'])))
+            inserted += cur.rowcount or 0
+        print(f'  inserted: {inserted}', flush=True)
+
+        # ===== 4. requirement_resource 复制(remap resource_id)=====
+        print('\n[4] 复制 requirement_resource(remap resource_id)', flush=True)
+        cur.execute("""SELECT rr.requirement_id, rr.resource_id
+                       FROM requirement_resource rr
+                       JOIN resource r ON r.id=rr.resource_id
+                       WHERE r.version='dev_abstract'""")
+        rr_rows = cur.fetchall()
+        print(f'  源 req_resource 行数: {len(rr_rows)}', flush=True)
+        inserted = 0
+        for row in rr_rows:
+            cur.execute("""INSERT INTO requirement_resource (requirement_id, resource_id)
+                           VALUES (%s, %s) ON CONFLICT DO NOTHING""",
+                        (row['requirement_id'], remap(row['resource_id'])))
+            inserted += cur.rowcount or 0
+        print(f'  inserted: {inserted}', flush=True)
+
+        # ===== 5. strategy 从 backup 恢复 =====
+        print('\n[5] strategy 从 bk_20260422_strategy 恢复', flush=True)
+        cur.execute("""SELECT id, name, description, body, status, created_at, updated_at
+                       FROM bk_20260422_strategy""")
+        bk_strats = cur.fetchall()
+        print(f'  备份 strategy 行数: {len(bk_strats)}', flush=True)
+        inserted = 0
+        for st in bk_strats:
+            cur.execute("""INSERT INTO strategy (id, name, description, body, status,
+                                                 created_at, updated_at, version)
+                           VALUES (%s,%s,%s,%s,%s,%s,%s,'dev_dedup')
+                           ON CONFLICT (id) DO NOTHING""",
+                        (st['id'], st['name'], st['description'],
+                         psycopg2.extras.Json(st['body']) if st['body'] is not None else None,
+                         st['status'], st['created_at'], st['updated_at']))
+            inserted += cur.rowcount or 0
+        print(f'  inserted: {inserted}', flush=True)
+
+        # ===== 6. requirement_strategy 从 backup 恢复 =====
+        print('\n[6] requirement_strategy 从备份恢复(is_selected=TRUE)', flush=True)
+        cur.execute("""SELECT column_name FROM information_schema.columns
+                       WHERE table_name='bk_20260422_requirement_strategy' ORDER BY ordinal_position""")
+        bk_rs_cols = [r['column_name'] for r in cur.fetchall()]
+        print(f'  bk_rs 列: {bk_rs_cols}', flush=True)
+        cur.execute(f"SELECT * FROM bk_20260422_requirement_strategy")
+        bk_rs = cur.fetchall()
+        print(f'  bk req_strategy 行数: {len(bk_rs)}', flush=True)
+        inserted = 0
+        # live req_strategy 目前有 (req_id, strat_id, [role], is_selected, coverage_score, coverage_explanation)
+        cur.execute("""SELECT column_name FROM information_schema.columns
+                       WHERE table_name='requirement_strategy' ORDER BY ordinal_position""")
+        live_rs_cols = [r['column_name'] for r in cur.fetchall()]
+        print(f'  live_rs 列: {live_rs_cols}', flush=True)
+        for row in bk_rs:
+            # 只取 requirement_id / strategy_id;is_selected 默认 TRUE(备份都是选中的)
+            # 其他 coverage 字段留 NULL
+            cur.execute("""INSERT INTO requirement_strategy (requirement_id, strategy_id, is_selected)
+                           VALUES (%s, %s, TRUE) ON CONFLICT DO NOTHING""",
+                        (row['requirement_id'], row['strategy_id']))
+            inserted += cur.rowcount or 0
+        print(f'  inserted: {inserted}', flush=True)
+
+        # ===== 7. strategy_capability 从 backup 恢复(remap cap_id)=====
+        print('\n[7] strategy_capability 从备份恢复(remap cap_id)', flush=True)
+        cur.execute("SELECT strategy_id, capability_id FROM bk_20260422_strategy_capability")
+        bk_sc = cur.fetchall()
+        print(f'  bk strat_cap 行数: {len(bk_sc)}', flush=True)
+        inserted = 0
+        skipped_nocap = 0
+        # 检查哪些 backup cap_id 存在于当前 capability(dev_abstract)
+        cur.execute("SELECT id FROM capability WHERE version='dev_abstract'")
+        valid_old_caps = {r['id'] for r in cur.fetchall()}
+        for row in bk_sc:
+            if row['capability_id'] not in valid_old_caps:
+                skipped_nocap += 1; continue
+            cur.execute("""INSERT INTO strategy_capability (strategy_id, capability_id)
+                           VALUES (%s, %s) ON CONFLICT DO NOTHING""",
+                        (row['strategy_id'], remap(row['capability_id'])))
+            inserted += cur.rowcount or 0
+        print(f'  inserted: {inserted}  (skipped: {skipped_nocap} cap_id 不在当前 cap 表)', flush=True)
+
+        # ===== 8. strategy_resource 从 backup 恢复(remap resource_id)=====
+        print('\n[8] strategy_resource 从备份恢复(remap resource_id)', flush=True)
+        cur.execute("SELECT strategy_id, resource_id FROM bk_20260422_strategy_resource")
+        bk_sr = cur.fetchall()
+        print(f'  bk strat_resource 行数: {len(bk_sr)}', flush=True)
+        inserted = 0; skipped_nores = 0
+        cur.execute("SELECT id FROM resource WHERE version='dev_abstract'")
+        valid_old_ress = {r['id'] for r in cur.fetchall()}
+        for row in bk_sr:
+            if row['resource_id'] not in valid_old_ress:
+                skipped_nores += 1; continue
+            cur.execute("""INSERT INTO strategy_resource (strategy_id, resource_id)
+                           VALUES (%s, %s) ON CONFLICT DO NOTHING""",
+                        (row['strategy_id'], remap(row['resource_id'])))
+            inserted += cur.rowcount or 0
+        print(f'  inserted: {inserted}  (skipped: {skipped_nores} resource_id 不在当前 resource 表)', flush=True)
+
+        # ===== 汇总 =====
+        print('\n=== 迁移后汇总 ===', flush=True)
+        for tbl in ['capability', 'resource', 'strategy', 'knowledge']:
+            cur.execute(f"SELECT version, COUNT(*) n FROM {tbl} GROUP BY version ORDER BY n DESC")
+            parts = [f'{r["version"]}={r["n"]}' for r in cur.fetchall()]
+            print(f'  {tbl}: {", ".join(parts)}', flush=True)
+        for tbl in ['requirement_capability', 'requirement_resource',
+                    'requirement_strategy', 'strategy_capability', 'strategy_resource']:
+            cur.execute(f'SELECT COUNT(*) c FROM {tbl}')
+            print(f'  {tbl}: {cur.fetchone()["c"]}', flush=True)
+    finally:
+        cur.close(); s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 98 - 0
knowhub/scripts/version_step3_verify.py

@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+"""Step 3: 回归验证 dev_abstract / dev_dedup 双版本查询是否对称且各自正确。"""
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+
+
+def q(cur, sql, params=()):
+    cur.execute(sql, params)
+    return cur.fetchall()
+
+
+def qc(cur, sql, params=()):
+    cur.execute(sql, params)
+    return cur.fetchone()['c']
+
+
+def main():
+    s = PostgreSQLCapabilityStore()
+    cur = s._get_cursor()
+    try:
+        cur.execute("SET statement_timeout = '30s'")
+
+        # 1) 挑一个 req,两版各自看到的 strategy
+        print('\n=== Test 1: REQ_003 两版的 strategy(应分别看到 abstract 和 concrete)===', flush=True)
+        for v in ('dev_abstract', 'dev_dedup'):
+            rows = q(cur, """SELECT s.id, s.name, s.version, rs.is_selected
+                             FROM requirement_strategy rs
+                             JOIN strategy s ON s.id=rs.strategy_id
+                             WHERE rs.requirement_id='REQ_003' AND s.version=%s""", (v,))
+            print(f'  [{v}]: {len(rows)} strategies', flush=True)
+            for r in rows:
+                print(f'    - {r["id"]}: {r["name"][:40]}  selected={r["is_selected"]}', flush=True)
+
+        # 2) 每版的 strategy → cap 路径是否自洽
+        print('\n=== Test 2: cap 过滤是否隔离(dev_dedup 的 strat 不能指向 dev_abstract cap)===', flush=True)
+        for v in ('dev_abstract', 'dev_dedup'):
+            leak = qc(cur, """SELECT COUNT(*) c FROM strategy_capability sc
+                              JOIN strategy s ON s.id=sc.strategy_id
+                              JOIN capability c ON c.id=sc.capability_id
+                              WHERE s.version=%s AND c.version!=%s""", (v, v))
+            print(f'  [{v}] strat.ver={v} 但 cap.ver 不等于 {v}: {leak}  {"❌" if leak else "✓"}', flush=True)
+
+        # 3) 每版 strategy_resource 是否自洽
+        for v in ('dev_abstract', 'dev_dedup'):
+            leak = qc(cur, """SELECT COUNT(*) c FROM strategy_resource sr
+                              JOIN strategy s ON s.id=sr.strategy_id
+                              JOIN resource r ON r.id=sr.resource_id
+                              WHERE s.version=%s AND r.version!=%s""", (v, v))
+            print(f'  [{v}] strat_resource 跨版: {leak}  {"❌" if leak else "✓"}', flush=True)
+
+        # 4) req 覆盖度(每 req 在每版都有 strategy?)
+        print('\n=== Test 3: 每版 strategy 覆盖的 req 数量 ===', flush=True)
+        for v in ('dev_abstract', 'dev_dedup'):
+            n = qc(cur, """SELECT COUNT(DISTINCT rs.requirement_id) c
+                           FROM requirement_strategy rs
+                           JOIN strategy s ON s.id=rs.strategy_id
+                           WHERE s.version=%s""", (v,))
+            print(f'  [{v}]: {n}/99 reqs 有 strategy 链接', flush=True)
+
+        # 5) 约束验证:knowledge_cap ⊆ req_cap(dev_abstract 的核心约束)
+        print('\n=== Test 4: knowledge_cap ⊆ req_cap(dev_abstract 约束)===', flush=True)
+        viol = qc(cur, """SELECT COUNT(*) c FROM knowledge_capability kc
+                          JOIN requirement_knowledge rk ON rk.knowledge_id=kc.knowledge_id
+                          JOIN knowledge k ON k.id=kc.knowledge_id
+                          LEFT JOIN requirement_capability rc
+                            ON rc.requirement_id=rk.requirement_id
+                            AND rc.capability_id=kc.capability_id
+                          WHERE k.version='dev_abstract' AND rc.capability_id IS NULL""")
+        print(f'  违规: {viol}  {"❌" if viol else "✓"}', flush=True)
+
+        # 6) dev_dedup 约束:strat_cap ⊆ req_cap(dev_dedup 里 req 是每 cap 研究全集)
+        print('\n=== Test 5: dev_dedup: strat_cap ⊆ req_cap(每 req 的 strat 用的 cap ⊆ 该 req 的 cap)===', flush=True)
+        viol = qc(cur, """SELECT COUNT(*) c FROM strategy_capability sc
+                          JOIN strategy s ON s.id=sc.strategy_id
+                          JOIN requirement_strategy rs ON rs.strategy_id=sc.strategy_id
+                          LEFT JOIN requirement_capability rc
+                            ON rc.requirement_id=rs.requirement_id
+                            AND rc.capability_id=sc.capability_id
+                          JOIN capability c ON c.id=sc.capability_id
+                          WHERE s.version='dev_dedup' AND c.version='dev_dedup'
+                            AND rc.capability_id IS NULL""")
+        print(f'  违规: {viol}  {"❌" if viol else "✓"}', flush=True)
+
+        # 7) 交叉版本是否完全隔离(不会误查到另一版)
+        print('\n=== Test 6: 交叉版本隔离(dev_abstract 查询不应看到 dev_dedup 的 strat)===', flush=True)
+        n = qc(cur, """SELECT COUNT(*) c FROM strategy WHERE version='dev_abstract'""")
+        print(f'  dev_abstract strat: {n}  (预期 26)  {"✓" if n==26 else "❌"}', flush=True)
+        n = qc(cur, """SELECT COUNT(*) c FROM strategy WHERE version='dev_dedup'""")
+        print(f'  dev_dedup strat: {n}  (预期 99)  {"✓" if n==99 else "❌"}', flush=True)
+
+    finally:
+        cur.close(); s.close()
+
+
+if __name__ == '__main__':
+    main()

+ 30 - 0
knowhub/scripts/version_store_smoke_test.py

@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+"""Smoke-test: 确认 store 读路径按 contextvar 过滤版本。"""
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent))
+from knowhub.knowhub_db.pg_strategy_store import PostgreSQLStrategyStore
+from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
+from knowhub.knowhub_db.pg_store import PostgreSQLStore
+from knowhub.knowhub_db.version_context import set_version
+
+
+def main():
+    strat = PostgreSQLStrategyStore()
+    cap = PostgreSQLCapabilityStore()
+    know = PostgreSQLStore()
+    try:
+        for v in ('dev_abstract', 'dev_dedup'):
+            set_version(v)
+            print(f'\n=== active_version = {v} ===', flush=True)
+            print(f'  strategy.count()   : {strat.count()}', flush=True)
+            print(f'  capability.count() : {cap.count()}', flush=True)
+            print(f'  knowledge.count()  : {know.count()}', flush=True)
+            sample = strat.list_all(limit=2)
+            print(f'  strategy sample    : {[(s["id"], s["version"]) for s in sample]}', flush=True)
+    finally:
+        strat.close(); cap.close(); know.close()
+
+
+if __name__ == '__main__':
+    main()

+ 34 - 13
knowhub/server.py

@@ -52,6 +52,7 @@ from knowhub.knowhub_db.pg_tool_store import PostgreSQLToolStore
 from knowhub.knowhub_db.pg_capability_store import PostgreSQLCapabilityStore
 from knowhub.knowhub_db.pg_requirement_store import PostgreSQLRequirementStore
 from knowhub.knowhub_db.pg_strategy_store import PostgreSQLStrategyStore
+from knowhub.knowhub_db.version_context import set_version as _set_active_version, DEFAULT_VERSION as _DEFAULT_VERSION
 from knowhub.embeddings import get_embedding, get_embeddings_batch
 
 BRAND_NAME    = os.getenv("BRAND_NAME", "KnowHub")
@@ -822,6 +823,22 @@ async def serve_itemsets():
     return {"error": "itemsets_all.json not found"}
 
 
+# --- 数据版本上下文中间件 ---
+# 从请求头 X-KnowHub-Version 或 query ?version=xxx 读版本,写入 contextvar;
+# 全链路的 store 查询都从 contextvar 读 active version 做过滤。
+_ALLOWED_VERSIONS = {'dev_abstract', 'dev_dedup', 'tao_dev', 'v0'}
+
+@app.middleware("http")
+async def set_active_version(request: Request, call_next):
+    v = request.headers.get('X-KnowHub-Version') or request.query_params.get('version') or _DEFAULT_VERSION
+    if v not in _ALLOWED_VERSIONS:
+        v = _DEFAULT_VERSION
+    _set_active_version(v)
+    response = await call_next(request)
+    response.headers['X-KnowHub-Version'] = v
+    return response
+
+
 # --- 缓存自动失效中间件 ---
 # 任何对核心实体的写操作(POST/PATCH/DELETE)自动清除对应缓存
 _DASHBOARD_INVALIDATE_PREFIXES = ("/api/requirement", "/api/capability", "/api/tool", "/api/strategy", "/api/knowledge")
@@ -2690,16 +2707,15 @@ async def get_relations(table_name: str, request: Request):
 
 # --- Dashboard Snapshot (缓存聚合接口) ---
 
-_dashboard_snapshot_cache: Optional[dict] = None
-_dashboard_snapshot_ts: float = 0
+_dashboard_snapshot_cache: Dict[str, dict] = {}   # 按 version 分桶
+_dashboard_snapshot_ts: Dict[str, float] = {}
 _DASHBOARD_CACHE_TTL = 24 * 3600  # 24 小时
 
 
 def _invalidate_dashboard_cache():
-    """数据写入后调用,清除 dashboard 快照缓存"""
-    global _dashboard_snapshot_cache, _dashboard_snapshot_ts
-    _dashboard_snapshot_cache = None
-    _dashboard_snapshot_ts = 0
+    """数据写入后调用,清除 dashboard 快照缓存(所有版本)"""
+    _dashboard_snapshot_cache.clear()
+    _dashboard_snapshot_ts.clear()
 
 
 def _build_dashboard_snapshot() -> dict:
@@ -2729,15 +2745,20 @@ def _build_dashboard_snapshot() -> dict:
 
 @app.get("/api/dashboard/snapshot")
 def get_dashboard_snapshot():
-    """返回 Dashboard 所需的全部数据快照,带服务端内存缓存(24h TTL,写入时失效)"""
-    global _dashboard_snapshot_cache, _dashboard_snapshot_ts
+    """返回 Dashboard 所需的全部数据快照,带服务端内存缓存(24h TTL,写入时失效)。按 version 分桶。"""
+    from knowhub.knowhub_db.version_context import get_version
+    v = get_version()
     now = time.time()
-    if _dashboard_snapshot_cache and (now - _dashboard_snapshot_ts < _DASHBOARD_CACHE_TTL):
-        return _dashboard_snapshot_cache
+    cached = _dashboard_snapshot_cache.get(v)
+    ts = _dashboard_snapshot_ts.get(v, 0)
+    if cached and (now - ts < _DASHBOARD_CACHE_TTL):
+        return cached
     try:
-        _dashboard_snapshot_cache = _build_dashboard_snapshot()
-        _dashboard_snapshot_ts = now
-        return _dashboard_snapshot_cache
+        snap = _build_dashboard_snapshot()
+        snap['version'] = v
+        _dashboard_snapshot_cache[v] = snap
+        _dashboard_snapshot_ts[v] = now
+        return snap
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))