Files
AstrBot/astrbot/dashboard/routes/knowledge_base.py
Gargantua cc72c01c0e fix: improve knowledge base upload error messages (#7534)
* fix: improve knowledge base upload error messages

* fix: deduplicate knowledge base upload logs

* fix: handle type errors in kb embedding validation
2026-04-14 16:27:06 +08:00

1289 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""知识库管理 API 路由"""
import asyncio
import os
import traceback
import uuid
from typing import Any
import aiofiles
from quart import request
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from ..utils import generate_tsne_visualization
from .route import Response, Route, RouteContext
class KnowledgeBaseRoute(Route):
"""知识库管理路由
提供知识库、文档、检索、会话配置等 API 接口
"""
def __init__(
self,
context: RouteContext,
core_lifecycle: AstrBotCoreLifecycle,
) -> None:
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.kb_manager = None # 延迟初始化
self.kb_db = None
self.session_config_db = None # 会话配置数据库
self.retrieval_manager = None
self.upload_progress = {} # 存储上传进度 {task_id: {status, file_index, file_total, stage, current, total}}
self.upload_tasks = {} # 存储后台上传任务 {task_id: {"status", "result", "error"}}
# 注册路由
self.routes = {
# 知识库管理
"/kb/list": ("GET", self.list_kbs),
"/kb/create": ("POST", self.create_kb),
"/kb/get": ("GET", self.get_kb),
"/kb/update": ("POST", self.update_kb),
"/kb/delete": ("POST", self.delete_kb),
"/kb/stats": ("GET", self.get_kb_stats),
# 文档管理
"/kb/document/list": ("GET", self.list_documents),
"/kb/document/upload": ("POST", self.upload_document),
"/kb/document/import": ("POST", self.import_documents),
"/kb/document/upload/url": ("POST", self.upload_document_from_url),
"/kb/document/upload/progress": ("GET", self.get_upload_progress),
"/kb/document/get": ("GET", self.get_document),
"/kb/document/delete": ("POST", self.delete_document),
# # 块管理
"/kb/chunk/list": ("GET", self.list_chunks),
"/kb/chunk/delete": ("POST", self.delete_chunk),
# # 多媒体管理
# "/kb/media/list": ("GET", self.list_media),
# "/kb/media/delete": ("POST", self.delete_media),
# 检索
"/kb/retrieve": ("POST", self.retrieve),
}
self.register_routes()
def _get_kb_manager(self):
return self.core_lifecycle.kb_manager
def _init_task(self, task_id: str, status: str = "pending") -> None:
self.upload_tasks[task_id] = {
"status": status,
"result": None,
"error": None,
}
def _set_task_result(
self, task_id: str, status: str, result: Any = None, error: str | None = None
) -> None:
self.upload_tasks[task_id] = {
"status": status,
"result": result,
"error": error,
}
if task_id in self.upload_progress:
self.upload_progress[task_id]["status"] = status
def _update_progress(
self,
task_id: str,
*,
status: str | None = None,
file_index: int | None = None,
file_name: str | None = None,
stage: str | None = None,
current: int | None = None,
total: int | None = None,
) -> None:
if task_id not in self.upload_progress:
return
p = self.upload_progress[task_id]
if status is not None:
p["status"] = status
if file_index is not None:
p["file_index"] = file_index
if file_name is not None:
p["file_name"] = file_name
if stage is not None:
p["stage"] = stage
if current is not None:
p["current"] = current
if total is not None:
p["total"] = total
def _make_progress_callback(self, task_id: str, file_idx: int, file_name: str):
async def _callback(stage: str, current: int, total: int) -> None:
self._update_progress(
task_id,
status="processing",
file_index=file_idx,
file_name=file_name,
stage=stage,
current=current,
total=total,
)
return _callback
@staticmethod
def _format_failed_doc_error(file_name: str, error: Exception) -> str:
message = str(error).strip() or "上传失败:发生未知错误。"
if message.startswith(file_name):
return message
return f"{file_name}: {message}"
async def _background_upload_task(
self,
task_id: str,
kb_helper,
files_to_upload: list,
chunk_size: int,
chunk_overlap: int,
batch_size: int,
tasks_limit: int,
max_retries: int,
) -> None:
"""后台上传任务"""
try:
# 初始化任务状态
self._init_task(task_id, status="processing")
self.upload_progress[task_id] = {
"status": "processing",
"file_index": 0,
"file_total": len(files_to_upload),
"stage": "waiting",
"current": 0,
"total": 100,
}
uploaded_docs = []
failed_docs = []
for file_idx, file_info in enumerate(files_to_upload):
try:
# 更新整体进度
self._update_progress(
task_id,
status="processing",
file_index=file_idx,
file_name=file_info["file_name"],
stage="parsing",
current=0,
total=100,
)
# 创建进度回调函数
progress_callback = self._make_progress_callback(
task_id, file_idx, file_info["file_name"]
)
doc = await kb_helper.upload_document(
file_name=file_info["file_name"],
file_content=file_info["file_content"],
file_type=file_info["file_type"],
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=progress_callback,
)
uploaded_docs.append(doc.model_dump())
except Exception as e:
logger.error(f"上传文档 {file_info['file_name']} 失败: {e}")
failed_docs.append(
{
"file_name": file_info["file_name"],
"error": self._format_failed_doc_error(
file_info["file_name"], e
),
},
)
# 更新任务完成状态
result = {
"task_id": task_id,
"uploaded": uploaded_docs,
"failed": failed_docs,
"total": len(files_to_upload),
"success_count": len(uploaded_docs),
"failed_count": len(failed_docs),
}
self._set_task_result(task_id, "completed", result=result)
except Exception as e:
logger.error(f"后台上传任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc())
self._set_task_result(task_id, "failed", error=str(e))
async def _background_import_task(
self,
task_id: str,
kb_helper,
documents: list,
batch_size: int,
tasks_limit: int,
max_retries: int,
) -> None:
"""后台导入预切片文档任务"""
try:
# 初始化任务状态
self._init_task(task_id, status="processing")
self.upload_progress[task_id] = {
"status": "processing",
"file_index": 0,
"file_total": len(documents),
"stage": "waiting",
"current": 0,
"total": 100,
}
uploaded_docs = []
failed_docs = []
for file_idx, doc_info in enumerate(documents):
file_name = doc_info.get("file_name", f"imported_doc_{file_idx}")
chunks = doc_info.get("chunks", [])
try:
# 更新整体进度
self._update_progress(
task_id,
status="processing",
file_index=file_idx,
file_name=file_name,
stage="importing",
current=0,
total=100,
)
# 创建进度回调函数
progress_callback = self._make_progress_callback(
task_id, file_idx, file_name
)
# 调用 upload_document传入 pre_chunked_text
doc = await kb_helper.upload_document(
file_name=file_name,
file_content=None, # 预切片模式下不需要原始内容
file_type=doc_info.get("file_type")
or (
file_name.rsplit(".", 1)[-1].lower()
if "." in file_name
else "txt"
),
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=progress_callback,
pre_chunked_text=chunks,
)
uploaded_docs.append(doc.model_dump())
except Exception as e:
logger.error(f"导入文档 {file_name} 失败: {e}")
failed_docs.append(
{
"file_name": file_name,
"error": self._format_failed_doc_error(file_name, e),
},
)
# 更新任务完成状态
result = {
"task_id": task_id,
"uploaded": uploaded_docs,
"failed": failed_docs,
"total": len(documents),
"success_count": len(uploaded_docs),
"failed_count": len(failed_docs),
}
self._set_task_result(task_id, "completed", result=result)
except Exception as e:
logger.error(f"后台导入任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc())
self._set_task_result(task_id, "failed", error=str(e))
async def list_kbs(self):
"""获取知识库列表
Query 参数:
- page: 页码 (默认 1)
- page_size: 每页数量 (默认 20)
- refresh_stats: 是否刷新统计信息 (默认 false首次加载时可设为 true)
"""
try:
kb_manager = self._get_kb_manager()
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 20, type=int)
kbs = await kb_manager.list_kbs()
# 转换为字典列表
kb_list = []
for kb in kbs:
kb_dict = kb.model_dump()
# include init_error from KBHelper if present
kb_helper = await kb_manager.get_kb(kb.kb_id)
if kb_helper and kb_helper.init_error:
kb_dict["init_error"] = kb_helper.init_error
kb_list.append(kb_dict)
return (
Response()
.ok({"items": kb_list, "page": page, "page_size": page_size})
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"获取知识库列表失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"获取知识库列表失败: {e!s}").__dict__
async def create_kb(self):
"""创建知识库
Body:
- kb_name: 知识库名称 (必填)
- description: 描述 (可选)
- emoji: 图标 (可选)
- embedding_provider_id: 嵌入模型提供商ID (可选)
- rerank_provider_id: 重排序模型提供商ID (可选)
- chunk_size: 分块大小 (可选, 默认512)
- chunk_overlap: 块重叠大小 (可选, 默认50)
- top_k_dense: 密集检索数量 (可选, 默认50)
- top_k_sparse: 稀疏检索数量 (可选, 默认50)
- top_m_final: 最终返回数量 (可选, 默认5)
"""
try:
kb_manager = self._get_kb_manager()
data = await request.json
kb_name = data.get("kb_name")
if not kb_name:
return Response().error("知识库名称不能为空").__dict__
description = data.get("description")
emoji = data.get("emoji")
embedding_provider_id = data.get("embedding_provider_id")
rerank_provider_id = data.get("rerank_provider_id")
chunk_size = data.get("chunk_size")
chunk_overlap = data.get("chunk_overlap")
top_k_dense = data.get("top_k_dense")
top_k_sparse = data.get("top_k_sparse")
top_m_final = data.get("top_m_final")
# pre-check embedding dim
if not embedding_provider_id:
return Response().error("缺少参数 embedding_provider_id").__dict__
prv = await kb_manager.provider_manager.get_provider_by_id(
embedding_provider_id,
) # type: ignore
if not prv or not isinstance(prv, EmbeddingProvider):
return (
Response().error(f"嵌入模型不存在或类型错误({type(prv)})").__dict__
)
try:
vec = await prv.get_embedding("astrbot")
if len(vec) != prv.get_dim():
raise ValueError(
f"嵌入向量维度不匹配,实际是 {len(vec)},然而配置是 {prv.get_dim()}",
)
except Exception as e:
return Response().error(f"测试嵌入模型失败: {e!s}").__dict__
# pre-check rerank
if rerank_provider_id:
rerank_prv: RerankProvider = (
await kb_manager.provider_manager.get_provider_by_id(
rerank_provider_id,
)
) # type: ignore
if not rerank_prv:
return Response().error("重排序模型不存在").__dict__
# 检查重排序模型可用性
try:
res = await rerank_prv.rerank(
query="astrbot",
documents=["astrbot knowledge base"],
)
if not res:
raise ValueError("重排序模型返回结果异常")
except Exception as e:
return (
Response()
.error(f"测试重排序模型失败: {e!s},请检查平台日志输出。")
.__dict__
)
kb_helper = await kb_manager.create_kb(
kb_name=kb_name,
description=description,
emoji=emoji,
embedding_provider_id=embedding_provider_id,
rerank_provider_id=rerank_provider_id,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
top_k_dense=top_k_dense,
top_k_sparse=top_k_sparse,
top_m_final=top_m_final,
)
kb = kb_helper.kb
return Response().ok(kb.model_dump(), "创建知识库成功").__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"创建知识库失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"创建知识库失败: {e!s}").__dict__
async def get_kb(self):
"""获取知识库详情
Query 参数:
- kb_id: 知识库 ID (必填)
"""
try:
kb_manager = self._get_kb_manager()
kb_id = request.args.get("kb_id")
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
kb_helper = await kb_manager.get_kb(kb_id)
if not kb_helper:
return Response().error("知识库不存在").__dict__
kb = kb_helper.kb
return Response().ok(kb.model_dump()).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"获取知识库详情失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"获取知识库详情失败: {e!s}").__dict__
async def update_kb(self):
"""更新知识库
Body:
- kb_id: 知识库 ID (必填)
- kb_name: 新的知识库名称 (可选)
- description: 新的描述 (可选)
- emoji: 新的图标 (可选)
- embedding_provider_id: 新的嵌入模型提供商ID (可选)
- rerank_provider_id: 新的重排序模型提供商ID (可选)
- chunk_size: 分块大小 (可选)
- chunk_overlap: 块重叠大小 (可选)
- top_k_dense: 密集检索数量 (可选)
- top_k_sparse: 稀疏检索数量 (可选)
- top_m_final: 最终返回数量 (可选)
"""
try:
kb_manager = self._get_kb_manager()
data = await request.json
kb_id = data.get("kb_id")
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
kb_name = data.get("kb_name")
description = data.get("description")
emoji = data.get("emoji")
embedding_provider_id = data.get("embedding_provider_id")
rerank_provider_id = data.get("rerank_provider_id")
chunk_size = data.get("chunk_size")
chunk_overlap = data.get("chunk_overlap")
top_k_dense = data.get("top_k_dense")
top_k_sparse = data.get("top_k_sparse")
top_m_final = data.get("top_m_final")
# 检查是否至少提供了一个更新字段
if all(
v is None
for v in [
kb_name,
description,
emoji,
embedding_provider_id,
rerank_provider_id,
chunk_size,
chunk_overlap,
top_k_dense,
top_k_sparse,
top_m_final,
]
):
return Response().error("至少需要提供一个更新字段").__dict__
kb_helper = await kb_manager.update_kb(
kb_id=kb_id,
kb_name=kb_name,
description=description,
emoji=emoji,
embedding_provider_id=embedding_provider_id,
rerank_provider_id=rerank_provider_id,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
top_k_dense=top_k_dense,
top_k_sparse=top_k_sparse,
top_m_final=top_m_final,
)
if not kb_helper:
return Response().error("知识库不存在").__dict__
kb = kb_helper.kb
return Response().ok(kb.model_dump(), "更新知识库成功").__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"更新知识库失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"更新知识库失败: {e!s}").__dict__
async def delete_kb(self):
"""删除知识库
Body:
- kb_id: 知识库 ID (必填)
"""
try:
kb_manager = self._get_kb_manager()
data = await request.json
kb_id = data.get("kb_id")
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
success = await kb_manager.delete_kb(kb_id)
if not success:
return Response().error("知识库不存在").__dict__
return Response().ok(message="删除知识库成功").__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"删除知识库失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"删除知识库失败: {e!s}").__dict__
async def get_kb_stats(self):
"""获取知识库统计信息
Query 参数:
- kb_id: 知识库 ID (必填)
"""
try:
kb_manager = self._get_kb_manager()
kb_id = request.args.get("kb_id")
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
kb_helper = await kb_manager.get_kb(kb_id)
if not kb_helper:
return Response().error("知识库不存在").__dict__
kb = kb_helper.kb
stats = {
"kb_id": kb.kb_id,
"kb_name": kb.kb_name,
"doc_count": kb.doc_count,
"chunk_count": kb.chunk_count,
"created_at": kb.created_at.isoformat(),
"updated_at": kb.updated_at.isoformat(),
}
return Response().ok(stats).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"获取知识库统计失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"获取知识库统计失败: {e!s}").__dict__
# ===== 文档管理 API =====
async def list_documents(self):
"""获取文档列表
Query 参数:
- kb_id: 知识库 ID (必填)
- page: 页码 (默认 1)
- page_size: 每页数量 (默认 20)
"""
try:
kb_manager = self._get_kb_manager()
kb_id = request.args.get("kb_id")
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
kb_helper = await kb_manager.get_kb(kb_id)
if not kb_helper:
return Response().error("知识库不存在").__dict__
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 100, type=int)
offset = (page - 1) * page_size
limit = page_size
doc_list = await kb_helper.list_documents(offset=offset, limit=limit)
doc_list = [doc.model_dump() for doc in doc_list]
return (
Response()
.ok({"items": doc_list, "page": page, "page_size": page_size})
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"获取文档列表失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"获取文档列表失败: {e!s}").__dict__
async def upload_document(self):
"""上传文档
支持两种方式:
1. multipart/form-data 文件上传支持多文件最多10个
2. JSON 格式 base64 编码上传支持多文件最多10个
Form Data (multipart/form-data):
- kb_id: 知识库 ID (必填)
- file: 文件对象 (必填,可多个,字段名为 file, file1, file2, ... 或 files[])
JSON Body (application/json):
- kb_id: 知识库 ID (必填)
- files: 文件数组 (必填)
- file_name: 文件名 (必填)
- file_content: base64 编码的文件内容 (必填)
返回:
- task_id: 任务ID用于查询上传进度和结果
"""
try:
kb_manager = self._get_kb_manager()
# 检查 Content-Type
content_type = request.content_type
kb_id = None
chunk_size = None
chunk_overlap = None
batch_size = 32
tasks_limit = 3
max_retries = 3
files_to_upload = [] # 存储待上传的文件信息列表
if content_type and "multipart/form-data" not in content_type:
return (
Response().error("Content-Type 须为 multipart/form-data").__dict__
)
form_data = await request.form
files = await request.files
kb_id = form_data.get("kb_id")
chunk_size = int(form_data.get("chunk_size", 512))
chunk_overlap = int(form_data.get("chunk_overlap", 50))
batch_size = int(form_data.get("batch_size", 32))
tasks_limit = int(form_data.get("tasks_limit", 3))
max_retries = int(form_data.get("max_retries", 3))
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
# 收集所有文件
file_list = []
# 支持 file, file1, file2, ... 或 files[] 格式
for key in files.keys():
if key == "file" or key.startswith("file") or key == "files[]":
file_items = files.getlist(key)
file_list.extend(file_items)
if not file_list:
return Response().error("缺少文件").__dict__
# 限制文件数量
if len(file_list) > 10:
return Response().error("最多只能上传10个文件").__dict__
# 处理每个文件
for file in file_list:
file_name = file.filename
# 保存到临时文件
temp_file_path = os.path.join(
get_astrbot_temp_path(),
f"kb_upload_{uuid.uuid4()}_{file_name}",
)
await file.save(temp_file_path)
try:
# 异步读取文件内容
async with aiofiles.open(temp_file_path, "rb") as f:
file_content = await f.read()
# 提取文件类型
file_type = (
file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
)
files_to_upload.append(
{
"file_name": file_name,
"file_content": file_content,
"file_type": file_type,
},
)
finally:
# 清理临时文件
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
# 获取知识库
kb_helper = await kb_manager.get_kb(kb_id)
if not kb_helper:
return Response().error("知识库不存在").__dict__
# 生成任务ID
task_id = str(uuid.uuid4())
# 初始化任务状态
self._init_task(task_id, status="pending")
# 启动后台任务
asyncio.create_task(
self._background_upload_task(
task_id=task_id,
kb_helper=kb_helper,
files_to_upload=files_to_upload,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
),
)
return (
Response()
.ok(
{
"task_id": task_id,
"file_count": len(files_to_upload),
"message": "task created, processing in background",
},
)
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"上传文档失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"上传文档失败: {e!s}").__dict__
def _validate_import_request(self, data: dict):
kb_id = data.get("kb_id")
if not kb_id:
raise ValueError("缺少参数 kb_id")
documents = data.get("documents")
if not documents or not isinstance(documents, list):
raise ValueError("缺少参数 documents 或格式错误")
for doc in documents:
if "file_name" not in doc or "chunks" not in doc:
raise ValueError("文档格式错误,必须包含 file_name 和 chunks")
if not isinstance(doc["chunks"], list):
raise ValueError("chunks 必须是列表")
if not all(
isinstance(chunk, str) and chunk.strip() for chunk in doc["chunks"]
):
raise ValueError("chunks 必须是非空字符串列表")
batch_size = data.get("batch_size", 32)
tasks_limit = data.get("tasks_limit", 3)
max_retries = data.get("max_retries", 3)
return kb_id, documents, batch_size, tasks_limit, max_retries
async def import_documents(self):
"""导入预切片文档
Body:
- kb_id: 知识库 ID (必填)
- documents: 文档列表 (必填)
- file_name: 文件名 (必填)
- chunks: 切片列表 (必填, list[str])
- file_type: 文件类型 (可选, 默认从文件名推断或为 txt)
- batch_size: 批处理大小 (可选, 默认32)
- tasks_limit: 并发任务限制 (可选, 默认3)
- max_retries: 最大重试次数 (可选, 默认3)
"""
try:
kb_manager = self._get_kb_manager()
data = await request.json
kb_id, documents, batch_size, tasks_limit, max_retries = (
self._validate_import_request(data)
)
# 获取知识库
kb_helper = await kb_manager.get_kb(kb_id)
if not kb_helper:
return Response().error("知识库不存在").__dict__
# 生成任务ID
task_id = str(uuid.uuid4())
# 初始化任务状态
self._init_task(task_id, status="pending")
# 启动后台任务
asyncio.create_task(
self._background_import_task(
task_id=task_id,
kb_helper=kb_helper,
documents=documents,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
),
)
return (
Response()
.ok(
{
"task_id": task_id,
"doc_count": len(documents),
"message": "import task created, processing in background",
},
)
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"导入文档失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"导入文档失败: {e!s}").__dict__
async def get_upload_progress(self):
"""获取上传进度和结果
Query 参数:
- task_id: 任务 ID (必填)
返回状态:
- pending: 任务待处理
- processing: 任务处理中
- completed: 任务完成
- failed: 任务失败
"""
try:
task_id = request.args.get("task_id")
if not task_id:
return Response().error("缺少参数 task_id").__dict__
# 检查任务是否存在
if task_id not in self.upload_tasks:
return Response().error("找不到该任务").__dict__
task_info = self.upload_tasks[task_id]
status = task_info["status"]
# 构建返回数据
response_data = {
"task_id": task_id,
"status": status,
}
# 如果任务正在处理,返回进度信息
if status == "processing" and task_id in self.upload_progress:
response_data["progress"] = self.upload_progress[task_id]
# 如果任务完成,返回结果
if status == "completed":
response_data["result"] = task_info["result"]
# 清理已完成的任务
# del self.upload_tasks[task_id]
# if task_id in self.upload_progress:
# del self.upload_progress[task_id]
# 如果任务失败,返回错误信息
if status == "failed":
response_data["error"] = task_info["error"]
return Response().ok(response_data).__dict__
except Exception as e:
logger.error(f"获取上传进度失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"获取上传进度失败: {e!s}").__dict__
async def get_document(self):
"""获取文档详情
Query 参数:
- doc_id: 文档 ID (必填)
"""
try:
kb_manager = self._get_kb_manager()
kb_id = request.args.get("kb_id")
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
doc_id = request.args.get("doc_id")
if not doc_id:
return Response().error("缺少参数 doc_id").__dict__
kb_helper = await kb_manager.get_kb(kb_id)
if not kb_helper:
return Response().error("知识库不存在").__dict__
doc = await kb_helper.get_document(doc_id)
if not doc:
return Response().error("文档不存在").__dict__
return Response().ok(doc.model_dump()).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"获取文档详情失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"获取文档详情失败: {e!s}").__dict__
async def delete_document(self):
"""删除文档
Body:
- kb_id: 知识库 ID (必填)
- doc_id: 文档 ID (必填)
"""
try:
kb_manager = self._get_kb_manager()
data = await request.json
kb_id = data.get("kb_id")
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
doc_id = data.get("doc_id")
if not doc_id:
return Response().error("缺少参数 doc_id").__dict__
kb_helper = await kb_manager.get_kb(kb_id)
if not kb_helper:
return Response().error("知识库不存在").__dict__
await kb_helper.delete_document(doc_id)
return Response().ok(message="删除文档成功").__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"删除文档失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"删除文档失败: {e!s}").__dict__
async def delete_chunk(self):
"""删除文本块
Body:
- kb_id: 知识库 ID (必填)
- chunk_id: 块 ID (必填)
"""
try:
kb_manager = self._get_kb_manager()
data = await request.json
kb_id = data.get("kb_id")
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
chunk_id = data.get("chunk_id")
if not chunk_id:
return Response().error("缺少参数 chunk_id").__dict__
doc_id = data.get("doc_id")
if not doc_id:
return Response().error("缺少参数 doc_id").__dict__
kb_helper = await kb_manager.get_kb(kb_id)
if not kb_helper:
return Response().error("知识库不存在").__dict__
await kb_helper.delete_chunk(chunk_id, doc_id)
return Response().ok(message="删除文本块成功").__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"删除文本块失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"删除文本块失败: {e!s}").__dict__
async def list_chunks(self):
"""获取块列表
Query 参数:
- kb_id: 知识库 ID (必填)
- page: 页码 (默认 1)
- page_size: 每页数量 (默认 20)
"""
try:
kb_manager = self._get_kb_manager()
kb_id = request.args.get("kb_id")
doc_id = request.args.get("doc_id")
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 100, type=int)
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
if not doc_id:
return Response().error("缺少参数 doc_id").__dict__
kb_helper = await kb_manager.get_kb(kb_id)
offset = (page - 1) * page_size
limit = page_size
if not kb_helper:
return Response().error("知识库不存在").__dict__
chunk_list = await kb_helper.get_chunks_by_doc_id(
doc_id=doc_id,
offset=offset,
limit=limit,
)
return (
Response()
.ok(
data={
"items": chunk_list,
"page": page,
"page_size": page_size,
"total": await kb_helper.get_chunk_count_by_doc_id(doc_id),
},
)
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"获取块列表失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"获取块列表失败: {e!s}").__dict__
# ===== 检索 API =====
async def retrieve(self):
"""检索知识库
Body:
- query: 查询文本 (必填)
- kb_ids: 知识库 ID 列表 (必填)
- top_k: 返回结果数量 (可选, 默认 5)
- debug: 是否启用调试模式,返回 t-SNE 可视化图片 (可选, 默认 False)
"""
try:
kb_manager = self._get_kb_manager()
data = await request.json
query = data.get("query")
kb_names = data.get("kb_names")
debug = data.get("debug", False)
if not query:
return Response().error("缺少参数 query").__dict__
if not kb_names or not isinstance(kb_names, list):
return Response().error("缺少参数 kb_names 或格式错误").__dict__
top_k = data.get("top_k", 5)
results = await kb_manager.retrieve(
query=query,
kb_names=kb_names,
top_m_final=top_k,
)
result_list = []
if results:
result_list = results["results"]
response_data = {
"results": result_list,
"total": len(result_list),
"query": query,
}
# Debug 模式:生成 t-SNE 可视化
if debug:
try:
img_base64 = await generate_tsne_visualization(
query,
kb_names,
kb_manager,
)
if img_base64:
response_data["visualization"] = img_base64
except Exception as e:
logger.error(f"生成 t-SNE 可视化失败: {e}")
logger.error(traceback.format_exc())
response_data["visualization_error"] = str(e)
return Response().ok(response_data).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"检索失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"检索失败: {e!s}").__dict__
async def upload_document_from_url(self):
"""从 URL 上传文档
Body:
- kb_id: 知识库 ID (必填)
- url: 要提取内容的网页 URL (必填)
- chunk_size: 分块大小 (可选, 默认512)
- chunk_overlap: 块重叠大小 (可选, 默认50)
- batch_size: 批处理大小 (可选, 默认32)
- tasks_limit: 并发任务限制 (可选, 默认3)
- max_retries: 最大重试次数 (可选, 默认3)
返回:
- task_id: 任务ID用于查询上传进度和结果
"""
try:
kb_manager = self._get_kb_manager()
data = await request.json
kb_id = data.get("kb_id")
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
url = data.get("url")
if not url:
return Response().error("缺少参数 url").__dict__
chunk_size = data.get("chunk_size", 512)
chunk_overlap = data.get("chunk_overlap", 50)
batch_size = data.get("batch_size", 32)
tasks_limit = data.get("tasks_limit", 3)
max_retries = data.get("max_retries", 3)
enable_cleaning = data.get("enable_cleaning", False)
cleaning_provider_id = data.get("cleaning_provider_id")
# 获取知识库
kb_helper = await kb_manager.get_kb(kb_id)
if not kb_helper:
return Response().error("知识库不存在").__dict__
# 生成任务ID
task_id = str(uuid.uuid4())
# 初始化任务状态
self._init_task(task_id, status="pending")
# 启动后台任务
asyncio.create_task(
self._background_upload_from_url_task(
task_id=task_id,
kb_helper=kb_helper,
url=url,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
enable_cleaning=enable_cleaning,
cleaning_provider_id=cleaning_provider_id,
),
)
return (
Response()
.ok(
{
"task_id": task_id,
"url": url,
"message": "URL upload task created, processing in background",
},
)
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"从URL上传文档失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"从URL上传文档失败: {e!s}").__dict__
async def _background_upload_from_url_task(
self,
task_id: str,
kb_helper,
url: str,
chunk_size: int,
chunk_overlap: int,
batch_size: int,
tasks_limit: int,
max_retries: int,
enable_cleaning: bool,
cleaning_provider_id: str | None,
) -> None:
"""后台上传URL任务"""
try:
# 初始化任务状态
self._init_task(task_id, status="processing")
self.upload_progress[task_id] = {
"status": "processing",
"file_index": 0,
"file_total": 1,
"file_name": f"URL: {url}",
"stage": "extracting",
"current": 0,
"total": 100,
}
# 创建进度回调函数
progress_callback = self._make_progress_callback(task_id, 0, f"URL: {url}")
# 上传文档
doc = await kb_helper.upload_from_url(
url=url,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=progress_callback,
enable_cleaning=enable_cleaning,
cleaning_provider_id=cleaning_provider_id,
)
# 更新任务完成状态
result = {
"task_id": task_id,
"uploaded": [doc.model_dump()],
"failed": [],
"total": 1,
"success_count": 1,
"failed_count": 0,
}
self._set_task_result(task_id, "completed", result=result)
except Exception as e:
logger.error(f"后台上传URL任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc())
self._set_task_result(task_id, "failed", error=str(e))