mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
* fix: improve knowledge base upload error messages * fix: deduplicate knowledge base upload logs * fix: handle type errors in kb embedding validation
1289 lines
46 KiB
Python
1289 lines
46 KiB
Python
"""知识库管理 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))
|