elksmmx 17 часов назад
Родитель
С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: 0, percent: '0%', color: 'bg-purple-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.coveredPostsCnt, percent: stats.weightedCoveragePerc + '%', color: 'bg-indigo-400' },
     { label: '工序覆盖节点', value: 0, percent: '0%', color: 'bg-purple-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 (

+ 124 - 21
knowhub/server.py

@@ -19,7 +19,7 @@ from pathlib import Path
 import httpx
 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.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
@@ -146,24 +146,24 @@ def decrypt_content(resource_id: str, encrypted_text: str, provided_key: Optiona
         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)):
         return data
 
     # 字典类型递归处理
     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)):
-        return [serialize_milvus_result(item) for item in data]
+        return [to_serializable(item) for item in data]
 
     # 尝试转换为字典(对于有 to_dict 方法的对象)
     if hasattr(data, 'to_dict') and callable(getattr(data, 'to_dict')):
         try:
-            return serialize_milvus_result(data.to_dict())
+            return to_serializable(data.to_dict())
         except:
             pass
 
@@ -173,7 +173,7 @@ def serialize_milvus_result(data):
             # 强制转换为列表并递归处理
             result = []
             for item in data:
-                result.append(serialize_milvus_result(item))
+                result.append(to_serializable(item))
             return result
         except:
             pass
@@ -181,7 +181,7 @@ def serialize_milvus_result(data):
     # 尝试获取对象的属性字典
     if hasattr(data, '__dict__'):
         try:
-            return serialize_milvus_result(vars(data))
+            return to_serializable(vars(data))
         except:
             pass
 
@@ -985,6 +985,15 @@ class KnowledgeAskResponse(BaseModel):
     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):
     data: dict  # {tools, resources, knowledge}
     trace_id: str  # 必填:调用方的 trace_id
@@ -1009,6 +1018,24 @@ async def ask_knowledge_api(req: KnowledgeAskRequest):
         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)
 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),
     min_score: int = Query(default=3, ge=1, le=5),
     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 精排)"""
     try:
@@ -1128,20 +1158,26 @@ async def search_knowledge_api(
         filters.append('(status == "approved" or status == "checked")')
 
         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 个候选)
         recall_limit = top_k * 3
         candidates = pg_store.search(
             query_embedding=query_embedding,
             filters=filter_expr,
-            limit=recall_limit
+            limit=recall_limit,
+            relation_filters=relation_filters
         )
 
         if not candidates:
             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 精排)
         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,
     owner: 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:
@@ -1278,13 +1317,18 @@ def list_knowledge(
         # 如果没有过滤条件,查询所有
         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  # 设置一个合理的上限
-        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 降序排序(最新的在前)
         serialized_results.sort(key=lambda x: x.get('created_at', 0), reverse=True)
@@ -1321,7 +1365,7 @@ def get_all_tags():
         all_tags = set()
         for item in results:
             # 转换为标准字典
-            serialized_item = serialize_milvus_result(item)
+            serialized_item = to_serializable(item)
             tags_dict = serialized_item.get("tags", {})
             if isinstance(tags_dict, dict):
                 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"',
             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)}
     except Exception as e:
         print(f"[Pending] 错误: {e}")
@@ -1382,7 +1426,7 @@ def get_knowledge_status(knowledge_id: str):
         result = pg_store.get_by_id(knowledge_id)
         if not result:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
-        serialized = serialize_milvus_result(result)
+        serialized = to_serializable(result)
         return {
             "id": knowledge_id,
             "status": serialized.get("status", "approved"),
@@ -1405,7 +1449,7 @@ def get_knowledge(knowledge_id: str):
         if not result:
             raise HTTPException(status_code=404, detail=f"Knowledge not found: {knowledge_id}")
 
-        return serialize_milvus_result(result)
+        return to_serializable(result)
 
     except HTTPException:
         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 = [serialize_milvus_result(item) for item in all_knowledge]
+        all_knowledge = [to_serializable(item) for item in all_knowledge]
 
         if len(all_knowledge) < 2:
             return {"status": "ok", "message": f"知识库仅有 {len(all_knowledge)} 条,无需瘦身"}
@@ -2387,6 +2431,7 @@ def delete_requirement(req_id: str):
         raise
     except Exception as e:
         raise HTTPException(status_code=500, detail=str(e))
+<<<<<<< HEAD
 
 
 @app.post("/api/pattern/posts/batch")
@@ -2417,6 +2462,58 @@ def frontend():
     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 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")
 def serve_category_tree():
@@ -2426,6 +2523,7 @@ def serve_category_tree():
         return {"error": "Not Found"}
     return FileResponse(str(tree_file))
 
+<<<<<<< HEAD
 
 @app.get("/{frontend_path:path}")
 def frontend_spa_fallback(frontend_path: str):
@@ -2437,6 +2535,11 @@ def frontend_spa_fallback(frontend_path: str):
     if "." in Path(frontend_path).name:
         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"
     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)