Преглед изворни кода

feat: dashboard data snapshot

Talegorithm пре 13 часа
родитељ
комит
ad10693e36

+ 47 - 46
knowhub/frontend/src/pages/Dashboard.tsx

@@ -1,10 +1,10 @@
-import { useState, useEffect, useMemo, useRef, type ReactNode, type WheelEvent } from 'react';
+import { useState, useEffect, useMemo, useRef, Fragment, type ReactNode, type WheelEvent } from '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 { SideDrawer } from '../components/common/SideDrawer';
 import { cn } from '../lib/utils';
-import { getRequirements, getCapabilities, getTools, getKnowledge, getResource, batchGetPosts, getStrategies } from '../services/api';
+import { getRequirements, getCapabilities, getTools, getKnowledge, getResource, batchGetPosts, getStrategies, getDashboardSnapshot, batchGetResources } from '../services/api';
 
 // --- Dashboard 内存级全局缓存 (避免路由切换时重复发起耗时请求) ---
 let globalCacheLoaded = false;
@@ -476,9 +476,8 @@ function CoverageFlowBoard({ data }: { data: any }) {
           const farColor = selectedIsSource ? edge.targetColor : edge.sourceColor;
           const farValue = selectedIsSource ? edge.targetCovered : edge.sourceCovered;
           return (
-            <>
+            <Fragment key={`cross-badge-${edge.index}`}>
               <div
-                key={`cross-badge-start-${edge.index}`}
                 className="absolute -translate-x-1/2 -translate-y-1/2 z-50"
                 style={{ left: anchorX, top: edge.source === selectedDetailNode ? edge.sourceBadgeY : edge.targetBadgeY }}
               >
@@ -490,7 +489,6 @@ function CoverageFlowBoard({ data }: { data: any }) {
                 </div>
               </div>
               <div
-                key={`cross-badge-end-${edge.index}`}
                 className="absolute -translate-x-1/2 -translate-y-1/2 z-50"
                 style={{ left: farX, top: farY }}
               >
@@ -501,7 +499,7 @@ function CoverageFlowBoard({ data }: { data: any }) {
                   {farValue}
                 </div>
               </div>
-            </>
+            </Fragment>
           );
         })}
       </div>
@@ -1099,12 +1097,12 @@ function StrategyResourcesGrid({ resourceIds, onOpenPost }: { resourceIds: strin
 
     async function loadResources() {
       setLoading(true);
-      const map: Record<string, any> = {};
-
-      for (const rid of resourceIds) {
-        if (!isMounted) break;
-        try {
-          const res = await getResource(encodeURIComponent(rid));
+      try {
+        const resources = await batchGetResources(resourceIds);
+        if (!isMounted) return;
+        const map: Record<string, any> = {};
+        for (const rid of resourceIds) {
+          const res = resources[rid];
           if (res) {
             map[rid] = {
               title: res.title,
@@ -1117,16 +1115,13 @@ function StrategyResourcesGrid({ resourceIds, onOpenPost }: { resourceIds: strin
                 '本地 Case': res.metadata?.local_case_id,
               }
             };
-            // 每次加载完立刻更新 UI,提供渐进式呈现
-            setPosts({ ...map });
           }
-        } catch (e) {
-          console.error(`Failed to fetch resource ${rid}`, e);
         }
-      }
-
-      if (isMounted) {
-        setLoading(false);
+        if (isMounted) setPosts(map);
+      } catch (e) {
+        console.error('Failed to batch fetch resources', e);
+      } finally {
+        if (isMounted) setLoading(false);
       }
     }
 
@@ -1554,35 +1549,41 @@ export function Dashboard({ pendingNode, onPendingConsumed }: { pendingNode?: st
           return;
         }
 
-        setDashboardLoadingText('连接服务端:获取预备目录...');
-        const treeRes = await fetch('/category_tree.json');
-        const data = await treeRes.json();
-        setTreeData(data);
-        const leaves = getLeafNodes([data]);
+        setDashboardLoadingText('获取数据快照...');
 
-        setDashboardLoadingText('获取底座数据 (1/4): 核心需求池...');
-        const reqRes = await getRequirements(1000, 0);
+        let data: any;
+        let reqs: any[], caps: any[], tools: any[], procs: any[], know: any[];
 
-        setDashboardLoadingText('获取底座数据 (2/4): 能力组合池...');
-        const capRes = await getCapabilities(1000, 0);
-
-        setDashboardLoadingText('获取底座数据 (3/4): 外部工具映射...');
-        const toolRes = await getTools(1000, 0);
-
-        setDashboardLoadingText('获取底座数据 (4/4): 顶层策略与工序集...');
-        const procRes = await getStrategies(1000, 0);
-
-        let knowRes: any = { results: [] };
         try {
-          // setDashboardLoadingText('底层依赖获取:知识碎片集...'); 
-          knowRes = await getKnowledge(1, 1000);
-        } catch (e) { /* optional */ }
-
-        const reqs = reqRes.results || [];
-        const caps = capRes.results || [];
-        const tools = toolRes.results || [];
-        const procs = procRes.strategies || [];
-        const know = knowRes.results || [];
+          // 优先使用后端预计算快照(单请求,带服务端缓存)
+          const snapshot = await getDashboardSnapshot();
+          data = snapshot.tree;
+          reqs = snapshot.reqs || [];
+          caps = snapshot.caps || [];
+          tools = snapshot.tools || [];
+          procs = snapshot.procs || [];
+          know = snapshot.know || [];
+        } catch {
+          // 快照接口不可用时,回退到并行请求
+          setDashboardLoadingText('回退模式:并行获取底座数据...');
+          const [treeRes, reqRes, capRes, toolRes, procRes, knowRes] = await Promise.all([
+            fetch('/category_tree.json').then(r => r.json()),
+            getRequirements(1000, 0),
+            getCapabilities(1000, 0),
+            getTools(1000, 0),
+            getStrategies(1000, 0),
+            getKnowledge(1, 1000).catch(() => ({ results: [] })),
+          ]);
+          data = treeRes;
+          reqs = reqRes.results || [];
+          caps = capRes.results || [];
+          tools = toolRes.results || [];
+          procs = procRes.strategies || [];
+          know = knowRes.results || [];
+        }
+
+        setTreeData(data);
+        const leaves = getLeafNodes([data]);
         setDbData({ reqs, caps, tools, know, procs });
 
         const nameToNode: Record<string, any> = {};

+ 18 - 0
knowhub/frontend/src/services/api.ts

@@ -74,6 +74,24 @@ export const getResource = async (resourceId: string) => {
   return fetchWithCache(`/resource/${resourceId}`);
 };
 
+export const getDashboardSnapshot = async (force = false) => {
+  return fetchWithCache('/dashboard/snapshot', force);
+};
+
+export const batchGetResources = async (ids: string[]): Promise<Record<string, any>> => {
+  if (ids.length === 0) return {};
+  const resp = await fetch('/api/resource/batch', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ ids }),
+  });
+  if (!resp.ok) {
+    throw new Error(`batchGetResources failed with status ${resp.status}`);
+  }
+  const data = await resp.json();
+  return data.resources || {};
+};
+
 export const batchGetPosts = async (postIds: string[]): Promise<Record<string, any>> => {
   if (postIds.length === 0) return {};
   const resp = await fetch('/api/pattern/posts/batch', {

+ 132 - 0
knowhub/server.py

@@ -814,8 +814,79 @@ if STATIC_DIR.exists():
     app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
 
 
+# --- 缓存自动失效中间件 ---
+# 任何对核心实体的写操作(POST/PATCH/DELETE)自动清除对应缓存
+_DASHBOARD_INVALIDATE_PREFIXES = ("/api/requirement", "/api/capability", "/api/tool", "/api/strategy", "/api/knowledge")
+
+@app.middleware("http")
+async def auto_invalidate_caches(request: Request, call_next):
+    response = await call_next(request)
+    if request.method in ("POST", "PATCH", "PUT", "DELETE") and response.status_code < 400:
+        path = request.url.path
+        if any(path.startswith(p) for p in _DASHBOARD_INVALIDATE_PREFIXES):
+            _invalidate_dashboard_cache()
+        if path.startswith("/api/resource") and not path.endswith("/batch"):
+            _invalidate_resource_cache()
+    return response
+
+
 # --- Knowledge API ---
 
+# --- Resource Batch API ---
+
+# --- Resource 缓存(与 Dashboard 同 TTL,写入时失效) ---
+_resource_cache: Dict[str, dict] = {}
+_resource_cache_ts: float = 0
+
+
+def _invalidate_resource_cache():
+    global _resource_cache, _resource_cache_ts
+    _resource_cache.clear()
+    _resource_cache_ts = 0
+
+
+def _get_cached_resource(rid: str) -> Optional[dict]:
+    """从缓存取 resource,miss 时查 DB 并写入缓存"""
+    global _resource_cache_ts
+    now = time.time()
+    if now - _resource_cache_ts > _DASHBOARD_CACHE_TTL:
+        _resource_cache.clear()
+        _resource_cache_ts = now
+    if rid in _resource_cache:
+        return _resource_cache[rid]
+    row = pg_resource_store.get_by_id(rid)
+    if not row:
+        return None
+    entry = {
+        "id": row["id"],
+        "title": row["title"],
+        "body": row["body"],
+        "content_type": row.get("content_type", "text"),
+        "metadata": row.get("metadata", {}),
+        "images": row.get("images", []),
+    }
+    _resource_cache[rid] = entry
+    return entry
+
+
+@app.post("/api/resource/batch")
+def batch_get_resources(body: dict = Body(...)):
+    """批量获取 resource 基本信息(不含 toc/children/siblings 导航),用于 Dashboard 等场景。
+    带后端内存缓存(24h TTL,resource 写入时失效)。"""
+    ids = body.get("ids", [])
+    if not ids:
+        return {"resources": {}}
+    resources: Dict[str, dict] = {}
+    for rid in ids:
+        try:
+            entry = _get_cached_resource(rid)
+            if entry:
+                resources[rid] = entry
+        except Exception as e:
+            print(f"[batch_get_resources] Failed to fetch {rid}: {e}")
+    return {"resources": resources}
+
+
 @app.post("/api/resource", status_code=201)
 def submit_resource(resource: ResourceIn):
     """提交资源(存入 PostgreSQL resources 表)"""
@@ -2595,6 +2666,67 @@ async def get_relations(table_name: str, request: Request):
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
 
+# --- Dashboard Snapshot (缓存聚合接口) ---
+
+_dashboard_snapshot_cache: Optional[dict] = None
+_dashboard_snapshot_ts: float = 0
+_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
+
+
+def _build_dashboard_snapshot() -> dict:
+    """在后端一次性构建 Dashboard 所需的全部数据"""
+    tree_file = STATIC_DIR / "category_tree.json"
+    tree_data = None
+    if tree_file.exists():
+        tree_data = json.loads(tree_file.read_text(encoding="utf-8"))
+
+    reqs = pg_requirement_store.list_all(limit=1000, offset=0)
+    caps = pg_capability_store.list_all(limit=1000, offset=0)
+    tools = pg_tool_store.list_all(limit=1000, offset=0)
+    procs = pg_strategy_store.list_all(limit=1000, offset=0)
+    know_raw = pg_store.query('(status == "approved" or status == "checked")', limit=1000)
+    know = [to_serializable(r) for r in know_raw]
+
+    return {
+        "tree": tree_data,
+        "reqs": reqs,
+        "caps": caps,
+        "tools": tools,
+        "procs": procs,
+        "know": know,
+        "built_at": time.time(),
+    }
+
+
+@app.get("/api/dashboard/snapshot")
+def get_dashboard_snapshot():
+    """返回 Dashboard 所需的全部数据快照,带服务端内存缓存(24h TTL,写入时失效)"""
+    global _dashboard_snapshot_cache, _dashboard_snapshot_ts
+    now = time.time()
+    if _dashboard_snapshot_cache and (now - _dashboard_snapshot_ts < _DASHBOARD_CACHE_TTL):
+        return _dashboard_snapshot_cache
+    try:
+        _dashboard_snapshot_cache = _build_dashboard_snapshot()
+        _dashboard_snapshot_ts = now
+        return _dashboard_snapshot_cache
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+
+
+@app.post("/api/dashboard/invalidate")
+def invalidate_dashboard_cache():
+    """手动清除 dashboard 缓存"""
+    _invalidate_dashboard_cache()
+    return {"status": "ok"}
+
+
 @app.get("/category_tree.json")
 def serve_category_tree():
     """类别树JSON数据"""

+ 0 - 16
scratch_db_caps.py

@@ -1,16 +0,0 @@
-import os
-from dotenv import load_dotenv
-load_dotenv("/root/Agent/.env")
-
-import psycopg2
-conn = psycopg2.connect(
-    host=os.getenv('KNOWHUB_DB'),
-    port=int(os.getenv('KNOWHUB_PORT', 5432)),
-    user=os.getenv('KNOWHUB_USER'),
-    password=os.getenv('KNOWHUB_PASSWORD'),
-    database=os.getenv('KNOWHUB_DB_NAME')
-)
-cursor = conn.cursor()
-cursor.execute("SELECT capability_id, COUNT(*) FROM capability_knowledge GROUP BY capability_id ORDER BY COUNT(*) DESC LIMIT 10")
-for row in cursor.fetchall():
-    print(row)

+ 0 - 21
scratch_db_test.py

@@ -1,21 +0,0 @@
-import os
-from dotenv import load_dotenv
-load_dotenv("/root/Agent/.env")
-
-import sys
-sys.path.append("/root/Agent")
-from knowhub.knowhub_db.pg_store import PostgreSQLStore
-
-store = PostgreSQLStore()
-print("Total knowledge:", store.count())
-
-try:
-    print("\n--- Searching for CAP-000 ---")
-    results = store.query("id != ''", limit=10, relation_filters={"capability_id": "CAP-000"})
-    print("CAP-000 count:", len(results))
-    
-    print("\n--- Searching for cap_0 ---")
-    results2 = store.query("id != ''", limit=10, relation_filters={"capability_id": "cap_0"})
-    print("cap_0 count:", len(results2))
-except Exception as e:
-    print("Error:", e)

+ 0 - 21
scratch_db_test_case.py

@@ -1,21 +0,0 @@
-import asyncio
-import os
-from dotenv import load_dotenv
-load_dotenv("/root/Agent/.env")
-
-import sys
-sys.path.append("/root/Agent")
-from knowhub.knowhub_db.pg_store import PostgreSQLStore
-
-store = PostgreSQLStore()
-
-try:
-    print("--- Searching for CAP-001 (Uppercase) ---")
-    results = store.query("id != ''", limit=10, relation_filters={"capability_id": "CAP-001"})
-    print("CAP-001 count:", len(results))
-    
-    print("\n--- Searching for cap-001 (Lowercase) ---")
-    results2 = store.query("id != ''", limit=10, relation_filters={"capability_id": "cap-001"})
-    print("cap-001 count:", len(results2))
-except Exception as e:
-    print("Error:", e)

+ 0 - 46
scratch_search_test.py

@@ -1,46 +0,0 @@
-import urllib.request, urllib.parse, json
-
-# First test: exactly what user provided
-params1 = urllib.parse.urlencode({
-    "q": "散景 浅景深 逆光 光斑 背景虚化 轮廓光",
-    "capability_id": "CAP-001",
-    "types": "strategy,case,tool",
-    "top_k": 10,
-    "min_score": 3
-})
-try:
-    req1 = urllib.request.Request(f'http://localhost:8000/api/knowledge/search?{params1}')
-    with urllib.request.urlopen(req1) as f:
-        print('Search 1 (all types): count =', json.loads(f.read().decode('utf-8')).get('count', 0))
-except Exception as e:
-    print('Search 1 error:', e)
-
-# Second test: only one type
-params2 = urllib.parse.urlencode({
-    "q": "散景 浅景深 逆光 光斑 背景虚化 轮廓光",
-    "capability_id": "CAP-001",
-    "types": "case",
-    "top_k": 10,
-    "min_score": 3
-})
-try:
-    req2 = urllib.request.Request(f'http://localhost:8000/api/knowledge/search?{params2}')
-    with urllib.request.urlopen(req2) as f:
-        print('Search 2 (single type case): count =', json.loads(f.read().decode('utf-8')).get('count', 0))
-except Exception as e:
-    print('Search 2 error:', e)
-
-# Third test: no types filter
-params3 = urllib.parse.urlencode({
-    "q": "散景 浅景深 逆光 光斑 背景虚化 轮廓光",
-    "capability_id": "CAP-001",
-    "top_k": 10,
-    "min_score": 3
-})
-try:
-    req3 = urllib.request.Request(f'http://localhost:8000/api/knowledge/search?{params3}')
-    with urllib.request.urlopen(req3) as f:
-        print('Search 3 (no types filter): count =', json.loads(f.read().decode('utf-8')).get('count', 0))
-except Exception as e:
-    print('Search 3 error:', e)
-

+ 0 - 18
scratch_test.py

@@ -1,18 +0,0 @@
-import asyncio
-import httpx
-import json
-
-async def main():
-    async with httpx.AsyncClient(timeout=30.0) as client:
-        # test search API
-        res = await client.get("http://localhost:8000/api/knowledge/search", params={"q": "test", "capability_id": "cap_0", "min_score": 1, "top_k": 5})
-        print("Search Response:")
-        print(json.dumps(res.json(), indent=2, ensure_ascii=False))
-
-        # test directly getting relations from DB or testing relation API
-        res2 = await client.get("http://localhost:8000/api/relation/capability_knowledge", params={"capability_id": "cap_0"})
-        print("Relation Response:")
-        print(json.dumps(res2.json(), indent=2, ensure_ascii=False))
-
-if __name__ == "__main__":
-    asyncio.run(main())

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
temp_b64.txt


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
temp_base64.txt


Неке датотеке нису приказане због велике количине промена