knowledge_tool.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. """
  2. 知识库工具路由
  3. 提供本地文档上传到 Viking 知识库的接口,无需 JWT 鉴权。
  4. 接口:POST /main/knowledgeTool/upload
  5. """
  6. import os
  7. import json
  8. import uuid
  9. import asyncio
  10. import tempfile
  11. from typing import Optional
  12. from fastapi import APIRouter, HTTPException, UploadFile, File, Form
  13. from pydantic import BaseModel
  14. from ..core.viking_client import upload_to_tos, add_doc_to_knowledge, delete_from_tos, delete_doc_from_knowledge
  15. from ..db.ai_config import get_config_by_app_name
  16. from ..db.mongo import save_knowledge_doc, get_knowledge_docs, get_knowledge_doc_by_doc_id, delete_knowledge_doc
  17. router = APIRouter()
  18. class KnowledgeDocsQuery(BaseModel):
  19. app_name: str
  20. page: int = 1
  21. page_size: int = 20
  22. class KnowledgeDeleteRequest(BaseModel):
  23. doc_id: str
  24. app_name: str
  25. # 支持的文档类型:后缀 → Viking doc_type 值
  26. ALLOWED_DOC_EXTENSIONS: dict[str, str] = {
  27. ".txt": "txt",
  28. ".doc": "doc",
  29. ".docx": "docx",
  30. ".pdf": "pdf",
  31. ".md": "markdown",
  32. ".markdown": "markdown",
  33. ".pptx": "pptx",
  34. ".ppt": "ppt",
  35. }
  36. # 单文件大小上限:50 MB
  37. MAX_DOC_SIZE = 50 * 1024 * 1024
  38. def _generate_doc_id() -> str:
  39. """生成符合 Viking 命名规范的唯一 doc_id(字母开头,仅含字母/数字/下划线)"""
  40. return f"doc_{uuid.uuid4().hex}"
  41. async def _cleanup_tos(object_key: str) -> None:
  42. """上传成功后异步清理 TOS 临时文件,失败仅打印日志不影响主流程。"""
  43. try:
  44. await delete_from_tos(object_key)
  45. except Exception as e:
  46. print(f"TOS 临时文件清理失败(key={object_key}): {e}")
  47. @router.post("/upload", summary="上传本地文档到知识库")
  48. async def upload_knowledge_doc(
  49. file: UploadFile = File(..., description="待上传文档,支持 txt/doc/docx/pdf/markdown/pptx/ppt"),
  50. app_name: str = Form(..., description="应用包名,用于查询对应知识库配置"),
  51. doc_id: Optional[str] = Form(None, description="文档唯一标识,不填则自动生成"),
  52. doc_name: Optional[str] = Form(None, description="文档显示名称,不填则使用原始文件名"),
  53. path_segments: Optional[str] = Form(None, description="目录路径 JSON 数组,如 [\"产品文档\",\"V2.0\"]"),
  54. tag_list: Optional[str] = Form(None, description="标签列表 JSON 数组,格式与 Viking API 一致"),
  55. ):
  56. """
  57. 上传本地文档到火山引擎 Viking 知识库。
  58. 流程:
  59. 1. 校验文件类型和大小
  60. 2. 从 ai_config 查询 knowledgeId(作为 resource_id)
  61. 3. 上传文件到 TOS 对象存储
  62. 4. 调用 Viking /api/knowledge/doc/v2/add 导入知识库
  63. 5. 返回 doc_id 和知识库信息
  64. """
  65. # ── 1. 校验文件类型 ──────────────────────────────────────────
  66. original_filename = file.filename or ""
  67. _, ext = os.path.splitext(original_filename.lower())
  68. if ext not in ALLOWED_DOC_EXTENSIONS:
  69. allowed = "、".join(ALLOWED_DOC_EXTENSIONS.keys())
  70. raise HTTPException(
  71. status_code=400,
  72. detail=f"不支持的文件类型 '{ext}',仅支持:{allowed}",
  73. )
  74. doc_type = ALLOWED_DOC_EXTENSIONS[ext]
  75. # ── 2. 读取并校验文件大小 ────────────────────────────────────
  76. file_bytes = await file.read()
  77. if len(file_bytes) > MAX_DOC_SIZE:
  78. raise HTTPException(
  79. status_code=400,
  80. detail=f"文件大小超过限制(最大 50 MB,当前 {len(file_bytes) / 1024 / 1024:.1f} MB)",
  81. )
  82. if len(file_bytes) == 0:
  83. raise HTTPException(status_code=400, detail="文件内容为空")
  84. # ── 3. 查询知识库配置 ────────────────────────────────────────
  85. ai_config = get_config_by_app_name(app_name)
  86. if not ai_config:
  87. raise HTTPException(status_code=404, detail=f"未找到 appName '{app_name}' 的配置")
  88. resource_id = ai_config.get("knowledgeId", "")
  89. if not resource_id:
  90. raise HTTPException(
  91. status_code=400,
  92. detail=f"appName '{app_name}' 的配置中未设置 knowledgeId",
  93. )
  94. # ── 4. 解析可选 JSON 参数 ────────────────────────────────────
  95. # 强制携带 app_name 标签,前端额外传入的标签追加在后面
  96. parsed_tag_list: list = [
  97. {"field_name": "app_name", "field_type": "string", "field_value": app_name}
  98. ]
  99. if tag_list:
  100. try:
  101. extra_tags = json.loads(tag_list)
  102. parsed_tag_list.extend(extra_tags)
  103. except json.JSONDecodeError:
  104. raise HTTPException(status_code=400, detail="tag_list 格式错误,需为合法的 JSON 数组")
  105. parsed_path_segments: list | None = None
  106. if path_segments:
  107. try:
  108. parsed_path_segments = json.loads(path_segments)
  109. except json.JSONDecodeError:
  110. raise HTTPException(status_code=400, detail="path_segments 格式错误,需为合法的 JSON 数组")
  111. # ── 5. 确定 doc_id 和 doc_name ──────────────────────────────
  112. final_doc_id = doc_id.strip() if doc_id else _generate_doc_id()
  113. final_doc_name = doc_name.strip() if doc_name else original_filename
  114. # ── 6. 写临时文件 ────────────────────────────────────────────
  115. tmp_path = None
  116. try:
  117. suffix = ext # 保留原后缀,TOS SDK 可能依赖文件名做 Content-Type 推断
  118. with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
  119. tmp.write(file_bytes)
  120. tmp_path = tmp.name
  121. # ── 7. 上传到 TOS ────────────────────────────────────────
  122. try:
  123. object_key, tos_uri = await upload_to_tos(tmp_path, app_name, original_filename)
  124. except RuntimeError as e:
  125. raise HTTPException(status_code=500, detail=str(e))
  126. except Exception as e:
  127. raise HTTPException(status_code=500, detail=f"TOS 上传失败: {str(e)}")
  128. # ── 8. 调用 Viking API 导入知识库 ───────────────────────
  129. try:
  130. kb_data = await add_doc_to_knowledge(
  131. resource_id=resource_id,
  132. doc_id=final_doc_id,
  133. tos_uri=tos_uri,
  134. doc_name=final_doc_name,
  135. doc_type=doc_type,
  136. tag_list=parsed_tag_list,
  137. path_segments=parsed_path_segments,
  138. )
  139. except ValueError as e:
  140. raise HTTPException(status_code=400, detail=str(e))
  141. except Exception as e:
  142. raise HTTPException(status_code=500, detail=f"导入知识库失败: {str(e)}")
  143. # ── 9. 异步删除 TOS 临时文件(不阻塞响应)─────────────────
  144. asyncio.create_task(_cleanup_tos(object_key))
  145. finally:
  146. # ── 10. 清理本地临时文件 ─────────────────────────────────
  147. if tmp_path and os.path.exists(tmp_path):
  148. os.unlink(tmp_path)
  149. # ── 11. 构造返回结果并存储到 MongoDB ─────────────────────────
  150. result = {
  151. "doc_id": kb_data.get("doc_id", final_doc_id),
  152. "resource_id": kb_data.get("resource_id", resource_id),
  153. "collection_name": kb_data.get("collection_name", ""),
  154. "project": kb_data.get("project", "default"),
  155. "doc_name": final_doc_name,
  156. "doc_type": doc_type,
  157. "app_name": app_name,
  158. }
  159. inserted_id = save_knowledge_doc(result.copy())
  160. result["id"] = inserted_id or ""
  161. return result
  162. @router.post("/docs", summary="分页查询知识库文档上传记录")
  163. async def list_knowledge_docs(request: KnowledgeDocsQuery):
  164. """
  165. 按 app_name 分页查询已上传的知识库文档记录,按上传时间倒序。
  166. 请求体使用 JSON,方便后续扩展查询条件。
  167. """
  168. page = max(1, request.page)
  169. page_size = request.page_size if 1 <= request.page_size <= 100 else 20
  170. return get_knowledge_docs(app_name=request.app_name, page=page, page_size=page_size)
  171. @router.post("/delete", summary="删除知识库文档")
  172. async def delete_knowledge_doc_api(request: KnowledgeDeleteRequest):
  173. """
  174. 删除指定文档,依次执行:
  175. 1. 按 doc_id 查询 MongoDB,获取 resource_id
  176. 2. 调用 Viking /api/knowledge/doc/delete 删除知识库文档
  177. 3. 删除 MongoDB 中的上传记录
  178. 注:TOS 临时文件由桶生命周期规则自动过期清理,此处不处理。
  179. """
  180. # ── 1. 查询 MongoDB 记录 ────────────────────────────────────
  181. doc = get_knowledge_doc_by_doc_id(request.doc_id)
  182. if not doc:
  183. raise HTTPException(status_code=404, detail=f"未找到 doc_id '{request.doc_id}' 的文档记录")
  184. if doc.get("app_name") != request.app_name:
  185. raise HTTPException(status_code=403, detail="doc_id 与 app_name 不匹配")
  186. resource_id = doc.get("resource_id", "")
  187. # ── 2. 删除 Viking 知识库文档 ───────────────────────────────
  188. try:
  189. await delete_doc_from_knowledge(resource_id=resource_id, doc_id=request.doc_id)
  190. except ValueError as e:
  191. raise HTTPException(status_code=400, detail=str(e))
  192. except Exception as e:
  193. raise HTTPException(status_code=500, detail=f"删除 Viking 知识库文档失败: {str(e)}")
  194. # ── 3. 删除 MongoDB 记录 ────────────────────────────────────
  195. delete_knowledge_doc(request.doc_id)
  196. return {"doc_id": request.doc_id, "message": "删除成功"}