elksmmx 18 часов назад
Родитель
Сommit
a779bb277f
2 измененных файлов с 126 добавлено и 23 удалено
  1. 2 2
      knowhub/frontend/src/pages/Dashboard.tsx
  2. 124 21
      knowhub/server.py

+ 2 - 2
knowhub/frontend/src/pages/Dashboard.tsx

@@ -14,13 +14,13 @@ function CoverageStats({ stats, weightTab, setWeightTab }: any) {
     { label: '需求覆盖节点', value: stats.reqCoveredNodes, percent: stats.reqCoveragePerc + '%', color: 'bg-indigo-400' },
     { label: '需求覆盖节点', value: stats.reqCoveredNodes, percent: stats.reqCoveragePerc + '%', color: 'bg-indigo-400' },
     { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-400' },
     { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-400' },
     { label: '原子能力覆盖节点', value: 0, percent: '0%', color: 'bg-teal-400' },
     { label: '原子能力覆盖节点', value: 0, percent: '0%', color: 'bg-teal-400' },
-    { label: '工具覆盖节点', value: stats.toolCoveredNodes, percent: (stats.totalLeaves ? (stats.toolCoveredNodes / stats.totalLeaves * 100).toFixed(1) : 0) + '%', color: 'bg-green-400' },
+    { label: '工具覆盖节点', value: stats.toolCoveredNodes, percent: (stats.totalLeaves ? (stats.toolCoveredNodes / stats.totalLeaves * 100).toFixed(1) : 0) + '%', color: 'bg-green-500' },
   ] : [
   ] : [
     { label: '全局节点', value: stats.totalPostsCnt, percent: '100%', color: 'bg-slate-400' },
     { label: '全局节点', value: stats.totalPostsCnt, percent: '100%', color: 'bg-slate-400' },
     { label: '需求覆盖节点', value: stats.coveredPostsCnt, percent: stats.weightedCoveragePerc + '%', color: 'bg-indigo-400' },
     { label: '需求覆盖节点', value: stats.coveredPostsCnt, percent: stats.weightedCoveragePerc + '%', color: 'bg-indigo-400' },
     { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-400' },
     { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-400' },
     { label: '原子能力覆盖节点', value: 0, percent: '0%', color: 'bg-teal-400' },
     { label: '原子能力覆盖节点', value: 0, percent: '0%', color: 'bg-teal-400' },
-    { label: '工具覆盖节点', value: stats.toolCoveredPostsCnt, percent: (stats.totalPostsCnt ? (stats.toolCoveredPostsCnt / stats.totalPostsCnt * 100).toFixed(1) : 0) + '%', color: 'bg-green-400' },
+    { label: '工具覆盖节点', value: stats.toolCoveredPostsCnt, percent: (stats.totalPostsCnt ? (stats.toolCoveredPostsCnt / stats.totalPostsCnt * 100).toFixed(1) : 0) + '%', color: 'bg-green-500' },
   ];
   ];
 
 
   return (
   return (

+ 124 - 21
knowhub/server.py

@@ -19,7 +19,7 @@ from pathlib import Path
 import httpx
 import httpx
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 
 
-from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks
+from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks, Request
 from fastapi.responses import HTMLResponse, FileResponse
 from fastapi.responses import HTMLResponse, FileResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
@@ -146,24 +146,24 @@ def decrypt_content(resource_id: str, encrypted_text: str, provided_key: Optiona
         return "[ENCRYPTED]"
         return "[ENCRYPTED]"
 
 
 
 
-def serialize_milvus_result(data):
-    """将 Milvus 返回的数据转换为可序列化的字典"""
+def to_serializable(data):
+    """通用序列化工具:把任意 Python 对象转换为 JSON 可序列化的原生类型"""
     # 基本类型直接返回
     # 基本类型直接返回
     if data is None or isinstance(data, (str, int, float, bool)):
     if data is None or isinstance(data, (str, int, float, bool)):
         return data
         return data
 
 
     # 字典类型递归处理
     # 字典类型递归处理
     if isinstance(data, dict):
     if isinstance(data, dict):
-        return {k: serialize_milvus_result(v) for k, v in data.items()}
+        return {k: to_serializable(v) for k, v in data.items()}
 
 
     # 列表/元组类型递归处理
     # 列表/元组类型递归处理
     if isinstance(data, (list, tuple)):
     if isinstance(data, (list, tuple)):
-        return [serialize_milvus_result(item) for item in data]
+        return [to_serializable(item) for item in data]
 
 
     # 尝试转换为字典(对于有 to_dict 方法的对象)
     # 尝试转换为字典(对于有 to_dict 方法的对象)
     if hasattr(data, 'to_dict') and callable(getattr(data, 'to_dict')):
     if hasattr(data, 'to_dict') and callable(getattr(data, 'to_dict')):
         try:
         try:
-            return serialize_milvus_result(data.to_dict())
+            return to_serializable(data.to_dict())
         except:
         except:
             pass
             pass
 
 
@@ -173,7 +173,7 @@ def serialize_milvus_result(data):
             # 强制转换为列表并递归处理
             # 强制转换为列表并递归处理
             result = []
             result = []
             for item in data:
             for item in data:
-                result.append(serialize_milvus_result(item))
+                result.append(to_serializable(item))
             return result
             return result
         except:
         except:
             pass
             pass
@@ -181,7 +181,7 @@ def serialize_milvus_result(data):
     # 尝试获取对象的属性字典
     # 尝试获取对象的属性字典
     if hasattr(data, '__dict__'):
     if hasattr(data, '__dict__'):
         try:
         try:
-            return serialize_milvus_result(vars(data))
+            return to_serializable(vars(data))
         except:
         except:
             pass
             pass
 
 
@@ -985,6 +985,15 @@ class KnowledgeAskResponse(BaseModel):
     sources: list[dict] = []  # [{id, task, content}]
     sources: list[dict] = []  # [{id, task, content}]
 
 
 
 
+class KnowledgeResearchRequest(BaseModel):
+    query: str
+    trace_id: str  # 必填:调用方的 trace_id,用于续跑
+
+class KnowledgeResearchResponse(BaseModel):
+    response: str
+    source_ids: list[str] = []
+    sources: list[dict] = []
+
 class KnowledgeUploadRequest(BaseModel):
 class KnowledgeUploadRequest(BaseModel):
     data: dict  # {tools, resources, knowledge}
     data: dict  # {tools, resources, knowledge}
     trace_id: str  # 必填:调用方的 trace_id
     trace_id: str  # 必填:调用方的 trace_id
@@ -1009,6 +1018,24 @@ async def ask_knowledge_api(req: KnowledgeAskRequest):
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
 
 
 
 
+@app.post("/api/knowledge/research")
+async def research_knowledge_api(req: KnowledgeResearchRequest):
+    """
+    智能深度调研。运行 Research Agent 执行全网搜集和总结,返回深度调研结果。
+    同步阻塞:Agent 运行完成后返回。
+    """
+    try:
+        from agents.research import research
+        result = await research(query=req.query, caller_trace_id=req.trace_id)
+        return KnowledgeResearchResponse(**result)
+
+    except Exception as e:
+        import traceback
+        traceback.print_exc()
+        print(f"[Knowledge Research] 错误: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+
 @app.post("/api/knowledge/upload", status_code=202)
 @app.post("/api/knowledge/upload", status_code=202)
 async def upload_knowledge_api(req: KnowledgeUploadRequest, background_tasks: BackgroundTasks):
 async def upload_knowledge_api(req: KnowledgeUploadRequest, background_tasks: BackgroundTasks):
     """
     """
@@ -1099,7 +1126,10 @@ async def search_knowledge_api(
     top_k: int = Query(default=5, ge=1, le=20),
     top_k: int = Query(default=5, ge=1, le=20),
     min_score: int = Query(default=3, ge=1, le=5),
     min_score: int = Query(default=3, ge=1, le=5),
     types: Optional[str] = None,
     types: Optional[str] = None,
-    owner: Optional[str] = None
+    owner: Optional[str] = None,
+    requirement_id: Optional[str] = None,
+    capability_id: Optional[str] = None,
+    tool_id: Optional[str] = None
 ):
 ):
     """检索知识(向量召回 + LLM 精排)"""
     """检索知识(向量召回 + LLM 精排)"""
     try:
     try:
@@ -1128,20 +1158,26 @@ async def search_knowledge_api(
         filters.append('(status == "approved" or status == "checked")')
         filters.append('(status == "approved" or status == "checked")')
 
 
         filter_expr = ' and '.join(filters) if filters else None
         filter_expr = ' and '.join(filters) if filters else None
+        
+        relation_filters = {}
+        if requirement_id: relation_filters['requirement_id'] = requirement_id
+        if capability_id: relation_filters['capability_id'] = capability_id
+        if tool_id: relation_filters['tool_id'] = tool_id
 
 
         # 3. 向量召回(3*k 个候选)
         # 3. 向量召回(3*k 个候选)
         recall_limit = top_k * 3
         recall_limit = top_k * 3
         candidates = pg_store.search(
         candidates = pg_store.search(
             query_embedding=query_embedding,
             query_embedding=query_embedding,
             filters=filter_expr,
             filters=filter_expr,
-            limit=recall_limit
+            limit=recall_limit,
+            relation_filters=relation_filters
         )
         )
 
 
         if not candidates:
         if not candidates:
             return {"results": [], "count": 0, "reranked": False}
             return {"results": [], "count": 0, "reranked": False}
 
 
         # 转换为可序列化的格式
         # 转换为可序列化的格式
-        serialized_candidates = [serialize_milvus_result(c) for c in candidates]
+        serialized_candidates = [to_serializable(c) for c in candidates]
 
 
         # 为了保证搜索的极致速度,直接返回向量召回的 top-k(跳过缓慢的 LLM 精排)
         # 为了保证搜索的极致速度,直接返回向量召回的 top-k(跳过缓慢的 LLM 精排)
         return {"results": serialized_candidates[:top_k], "count": len(serialized_candidates[:top_k]), "reranked": False}
         return {"results": serialized_candidates[:top_k], "count": len(serialized_candidates[:top_k]), "reranked": False}
@@ -1239,7 +1275,10 @@ def list_knowledge(
     scopes: Optional[str] = None,
     scopes: Optional[str] = None,
     owner: Optional[str] = None,
     owner: Optional[str] = None,
     tags: Optional[str] = None,
     tags: Optional[str] = None,
-    status: Optional[str] = None
+    status: Optional[str] = None,
+    requirement_id: Optional[str] = None,
+    capability_id: Optional[str] = None,
+    tool_id: Optional[str] = None
 ):
 ):
     """列出知识(支持后端筛选和分页)"""
     """列出知识(支持后端筛选和分页)"""
     try:
     try:
@@ -1278,13 +1317,18 @@ def list_knowledge(
         # 如果没有过滤条件,查询所有
         # 如果没有过滤条件,查询所有
         filter_expr = ' and '.join(filters) if filters else 'id != ""'
         filter_expr = ' and '.join(filters) if filters else 'id != ""'
 
 
-        # 查询 Milvus(先获取所有符合条件的数据)
-        # Milvus 的 limit 是总数限制,我们需要获取足够多的数据来支持分页
+        relation_filters = {}
+        if requirement_id: relation_filters['requirement_id'] = requirement_id
+        if capability_id: relation_filters['capability_id'] = capability_id
+        if tool_id: relation_filters['tool_id'] = tool_id
+
+        # 查询 Milvus/PG(先获取所有符合条件的数据)
+        # limit 是总数限制,我们需要获取足够多的数据来支持分页
         max_limit = 10000  # 设置一个合理的上限
         max_limit = 10000  # 设置一个合理的上限
-        results = pg_store.query(filter_expr, limit=max_limit)
+        results = pg_store.query(filter_expr, limit=max_limit, relation_filters=relation_filters)
 
 
         # 转换为可序列化的格式
         # 转换为可序列化的格式
-        serialized_results = [serialize_milvus_result(r) for r in results]
+        serialized_results = [to_serializable(r) for r in results]
 
 
         # 按 created_at 降序排序(最新的在前)
         # 按 created_at 降序排序(最新的在前)
         serialized_results.sort(key=lambda x: x.get('created_at', 0), reverse=True)
         serialized_results.sort(key=lambda x: x.get('created_at', 0), reverse=True)
@@ -1321,7 +1365,7 @@ def get_all_tags():
         all_tags = set()
         all_tags = set()
         for item in results:
         for item in results:
             # 转换为标准字典
             # 转换为标准字典
-            serialized_item = serialize_milvus_result(item)
+            serialized_item = to_serializable(item)
             tags_dict = serialized_item.get("tags", {})
             tags_dict = serialized_item.get("tags", {})
             if isinstance(tags_dict, dict):
             if isinstance(tags_dict, dict):
                 for key in tags_dict.keys():
                 for key in tags_dict.keys():
@@ -1342,7 +1386,7 @@ def get_pending_knowledge(limit: int = Query(default=50, ge=1, le=200)):
             'status == "pending" or status == "processing" or status == "dedup_passed" or status == "analyzing"',
             'status == "pending" or status == "processing" or status == "dedup_passed" or status == "analyzing"',
             limit=limit
             limit=limit
         )
         )
-        serialized = [serialize_milvus_result(r) for r in pending]
+        serialized = [to_serializable(r) for r in pending]
         return {"results": serialized, "count": len(serialized)}
         return {"results": serialized, "count": len(serialized)}
     except Exception as e:
     except Exception as e:
         print(f"[Pending] 错误: {e}")
         print(f"[Pending] 错误: {e}")
@@ -1382,7 +1426,7 @@ def get_knowledge_status(knowledge_id: str):
         result = pg_store.get_by_id(knowledge_id)
         result = pg_store.get_by_id(knowledge_id)
         if not result:
         if not result:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
-        serialized = serialize_milvus_result(result)
+        serialized = to_serializable(result)
         return {
         return {
             "id": knowledge_id,
             "id": knowledge_id,
             "status": serialized.get("status", "approved"),
             "status": serialized.get("status", "approved"),
@@ -1405,7 +1449,7 @@ def get_knowledge(knowledge_id: str):
         if not result:
         if not result:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
 
 
-        return serialize_milvus_result(result)
+        return to_serializable(result)
 
 
     except HTTPException:
     except HTTPException:
         raise
         raise
@@ -1748,7 +1792,7 @@ async def slim_knowledge(model: str = "google/gemini-2.5-flash-lite"):
         # 获取所有知识
         # 获取所有知识
         all_knowledge = pg_store.query('id != ""', limit=10000)
         all_knowledge = pg_store.query('id != ""', limit=10000)
         # 转换为可序列化的格式
         # 转换为可序列化的格式
-        all_knowledge = [serialize_milvus_result(item) for item in all_knowledge]
+        all_knowledge = [to_serializable(item) for item in all_knowledge]
 
 
         if len(all_knowledge) < 2:
         if len(all_knowledge) < 2:
             return {"status": "ok", "message": f"知识库仅有 {len(all_knowledge)} 条,无需瘦身"}
             return {"status": "ok", "message": f"知识库仅有 {len(all_knowledge)} 条,无需瘦身"}
@@ -2387,6 +2431,7 @@ def delete_requirement(req_id: str):
         raise
         raise
     except Exception as e:
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
         raise HTTPException(status_code=500, detail=str(e))
+<<<<<<< HEAD
 
 
 
 
 @app.post("/api/pattern/posts/batch")
 @app.post("/api/pattern/posts/batch")
@@ -2417,6 +2462,58 @@ def frontend():
     if not index_file.exists():
     if not index_file.exists():
         return HTMLResponse("<h1>KnowHub Frontend Not Found</h1><p>Please ensure knowhub/frontend/dist/index.html exists. Run 'yarn build' in frontend directory.</p>", status_code=404)
         return HTMLResponse("<h1>KnowHub Frontend Not Found</h1><p>Please ensure knowhub/frontend/dist/index.html exists. Run 'yarn build' in frontend directory.</p>", status_code=404)
     return FileResponse(str(index_file))
     return FileResponse(str(index_file))
+=======
+# ===== Relation API =====
+
+@app.get("/api/relation/{table_name}")
+async def get_relations(table_name: str, request: Request):
+    """通用关系表查询接口"""
+    allowed_tables = {
+        "capability_knowledge",
+        "capability_tool",
+        "knowledge_relation",
+        "knowledge_resource",
+        "requirement_capability",
+        "requirement_knowledge",
+        "tool_knowledge",
+        "tool_provider"
+    }
+    table_name = table_name.lower()
+    if table_name not in allowed_tables:
+        raise HTTPException(status_code=400, detail="Invalid table name")
+        
+    try:
+        params = dict(request.query_params)
+        
+        where_clauses = []
+        values = []
+        for k, v in params.items():
+            if k in ["limit", "offset"]: continue
+            where_clauses.append(f"{k} = %s")
+            values.append(v)
+        
+        query = f"SELECT * FROM {table_name}"
+        if where_clauses:
+            query += " WHERE " + " AND ".join(where_clauses)
+            
+        limit = int(params.get("limit", 100))
+        query += " LIMIT %s"
+        values.append(limit)
+        
+        cursor = pg_store._get_cursor()
+        try:
+            cursor.execute(query, tuple(values))
+            rows = cursor.fetchall()
+            if not rows:
+                 return {"results": [], "count": 0}
+            colnames = [desc[0] for desc in cursor.description]
+            results = [dict(zip(colnames, row)) for row in rows]
+            return {"results": results, "count": len(results)}
+        finally:
+            cursor.close()
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=str(e))
+>>>>>>> origin/main
 
 
 @app.get("/category_tree.json")
 @app.get("/category_tree.json")
 def serve_category_tree():
 def serve_category_tree():
@@ -2426,6 +2523,7 @@ def serve_category_tree():
         return {"error": "Not Found"}
         return {"error": "Not Found"}
     return FileResponse(str(tree_file))
     return FileResponse(str(tree_file))
 
 
+<<<<<<< HEAD
 
 
 @app.get("/{frontend_path:path}")
 @app.get("/{frontend_path:path}")
 def frontend_spa_fallback(frontend_path: str):
 def frontend_spa_fallback(frontend_path: str):
@@ -2437,6 +2535,11 @@ def frontend_spa_fallback(frontend_path: str):
     if "." in Path(frontend_path).name:
     if "." in Path(frontend_path).name:
         raise HTTPException(status_code=404, detail="Not Found")
         raise HTTPException(status_code=404, detail="Not Found")
 
 
+=======
+@app.get("/{full_path:path}")
+def frontend(full_path: str):
+    """KnowHub 管理前端 — 所有非 API 路径都返回 index.html,由 React Router 处理"""
+>>>>>>> origin/main
     index_file = STATIC_DIR / "index.html"
     index_file = STATIC_DIR / "index.html"
     if not index_file.exists():
     if not index_file.exists():
         return HTMLResponse("<h1>KnowHub Frontend Not Found</h1><p>Please ensure knowhub/frontend/dist/index.html exists. Run 'yarn build' in frontend directory.</p>", status_code=404)
         return HTMLResponse("<h1>KnowHub Frontend Not Found</h1><p>Please ensure knowhub/frontend/dist/index.html exists. Run 'yarn build' in frontend directory.</p>", status_code=404)