knowledge_tool.py 7.9 KB

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