|
@@ -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)
|