mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
* refactor: migrate to fastapi * structure refactor * fix: pyright fix * refactor: improve error handling and public messages in plugin services * feat(api): refactor API client integration and enhance request handling - Updated API client configuration to use a dedicated HTTP client. - Introduced utility functions for generating options, queries, and form data for API requests. - Refactored multiple API methods to utilize the new utility functions for improved consistency and readability. - Renamed types for clarity and updated import statements accordingly. feat(docs): add script to update OpenAPI JSON from YAML spec - Created a Python script to convert OpenAPI YAML specification to JSON format. - The script supports customizable input and output paths. - Ensured the script handles directory creation for output paths and validates the YAML structure. * fix * feat(auth): implement rate limiting for v1 login endpoint and enhance request handling * Refactor dashboard API routers to use legacy_router for backward compatibility - Changed all instances of dashboard_router to legacy_router across multiple API modules including platform, plugins, providers, sessions, skills, stats, subagents, t2i, tools, updates, and asgi_runtime. - Updated route definitions to ensure existing endpoints remain functional under the new router structure. - Introduced support for Quart request context in asgi_runtime to enhance compatibility with existing Quart-based plugins. - Added a test case to validate the functionality of the new Quart request context handling in plugin extensions. * chore: remove cli test * fix: update dashboard tests for fastapi migration * chore: satisfy ruff checks * fix: update openapi api key scopes * fix: sync config scope chip selection * fix: restore quart dependency * docs: clarify quart plugin api compatibility * docs: update openapi scope documentation * fix: use singular skill openapi scope * fix: hide update service exception details * fix: address fastapi review comments * fix: address dashboard review findings * docs: revert unrelated package deployment changes * docs: update agent api generation guidance * feat: add plugin page web api helpers * docs: add plugin page bridge demo * fix: type plugin upload files * fix: stabilize plugin page uploads * fix: type plugin web request proxy * docs: remove plugin page docs example * fix: authenticate plugin page SSE bridge
869 lines
31 KiB
Python
869 lines
31 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import traceback
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import aiofiles
|
|
|
|
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 astrbot.dashboard.utils import generate_tsne_visualization
|
|
|
|
|
|
class KnowledgeBaseServiceError(Exception):
|
|
pass
|
|
|
|
|
|
class KnowledgeBaseService:
|
|
def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
|
|
self.core_lifecycle = core_lifecycle
|
|
self.upload_progress: dict[str, dict[str, Any]] = {}
|
|
self.upload_tasks: dict[str, dict[str, Any]] = {}
|
|
|
|
@staticmethod
|
|
def _payload(data: object) -> dict[str, Any]:
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
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
|
|
progress = self.upload_progress[task_id]
|
|
if status is not None:
|
|
progress["status"] = status
|
|
if file_index is not None:
|
|
progress["file_index"] = file_index
|
|
if file_name is not None:
|
|
progress["file_name"] = file_name
|
|
if stage is not None:
|
|
progress["stage"] = stage
|
|
if current is not None:
|
|
progress["current"] = current
|
|
if total is not None:
|
|
progress["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[dict[str, Any]],
|
|
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 exc:
|
|
logger.error(f"上传文档 {file_info['file_name']} 失败: {exc}")
|
|
failed_docs.append(
|
|
{
|
|
"file_name": file_info["file_name"],
|
|
"error": self.format_failed_doc_error(
|
|
file_info["file_name"], exc
|
|
),
|
|
},
|
|
)
|
|
|
|
self.set_task_result(
|
|
task_id,
|
|
"completed",
|
|
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),
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
logger.error(f"后台上传任务 {task_id} 失败: {exc}")
|
|
logger.error(traceback.format_exc())
|
|
self.set_task_result(task_id, "failed", error=str(exc))
|
|
|
|
async def background_import_task(
|
|
self,
|
|
task_id: str,
|
|
kb_helper,
|
|
documents: list[dict[str, Any]],
|
|
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
|
|
)
|
|
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 exc:
|
|
logger.error(f"导入文档 {file_name} 失败: {exc}")
|
|
failed_docs.append(
|
|
{
|
|
"file_name": file_name,
|
|
"error": self.format_failed_doc_error(file_name, exc),
|
|
},
|
|
)
|
|
|
|
self.set_task_result(
|
|
task_id,
|
|
"completed",
|
|
result={
|
|
"task_id": task_id,
|
|
"uploaded": uploaded_docs,
|
|
"failed": failed_docs,
|
|
"total": len(documents),
|
|
"success_count": len(uploaded_docs),
|
|
"failed_count": len(failed_docs),
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
logger.error(f"后台导入任务 {task_id} 失败: {exc}")
|
|
logger.error(traceback.format_exc())
|
|
self.set_task_result(task_id, "failed", error=str(exc))
|
|
|
|
async def list_kbs(self, *, page: int, page_size: int) -> dict[str, Any]:
|
|
kb_manager = self.get_kb_manager()
|
|
kbs = await kb_manager.list_kbs()
|
|
|
|
kb_list = []
|
|
for kb in kbs:
|
|
kb_dict = kb.model_dump()
|
|
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 {"items": kb_list, "page": page, "page_size": page_size}
|
|
|
|
async def list_kbs_from_dashboard_query(self, *, page, page_size) -> dict[str, Any]:
|
|
return await self.list_kbs(
|
|
page=self._to_int(page, 1),
|
|
page_size=self._to_int(page_size, 20),
|
|
)
|
|
|
|
async def create_kb(self, data: object) -> tuple[dict[str, Any], str]:
|
|
kb_manager = self.get_kb_manager()
|
|
payload = self._payload(data)
|
|
kb_name = payload.get("kb_name")
|
|
if not kb_name:
|
|
raise KnowledgeBaseServiceError("知识库名称不能为空")
|
|
|
|
embedding_provider_id = payload.get("embedding_provider_id")
|
|
rerank_provider_id = payload.get("rerank_provider_id")
|
|
|
|
if not embedding_provider_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 embedding_provider_id")
|
|
provider = await kb_manager.provider_manager.get_provider_by_id(
|
|
embedding_provider_id,
|
|
)
|
|
if not provider or not isinstance(provider, EmbeddingProvider):
|
|
raise KnowledgeBaseServiceError(
|
|
f"嵌入模型不存在或类型错误({type(provider)})"
|
|
)
|
|
try:
|
|
vec = await provider.get_embedding("astrbot")
|
|
if len(vec) != provider.get_dim():
|
|
raise ValueError(
|
|
f"嵌入向量维度不匹配,实际是 {len(vec)},然而配置是 {provider.get_dim()}",
|
|
)
|
|
except Exception as exc:
|
|
raise KnowledgeBaseServiceError(f"测试嵌入模型失败: {exc!s}") from exc
|
|
|
|
if rerank_provider_id:
|
|
rerank_provider = await kb_manager.provider_manager.get_provider_by_id(
|
|
rerank_provider_id,
|
|
)
|
|
if not isinstance(rerank_provider, RerankProvider):
|
|
raise KnowledgeBaseServiceError("重排序模型不存在")
|
|
try:
|
|
result = await rerank_provider.rerank(
|
|
query="astrbot",
|
|
documents=["astrbot knowledge base"],
|
|
)
|
|
if not result:
|
|
raise ValueError("重排序模型返回结果异常")
|
|
except Exception as exc:
|
|
raise KnowledgeBaseServiceError(
|
|
f"测试重排序模型失败: {exc!s},请检查平台日志输出。"
|
|
) from exc
|
|
|
|
kb_helper = await kb_manager.create_kb(
|
|
kb_name=kb_name,
|
|
description=payload.get("description"),
|
|
emoji=payload.get("emoji"),
|
|
embedding_provider_id=embedding_provider_id,
|
|
rerank_provider_id=rerank_provider_id,
|
|
chunk_size=payload.get("chunk_size"),
|
|
chunk_overlap=payload.get("chunk_overlap"),
|
|
top_k_dense=payload.get("top_k_dense"),
|
|
top_k_sparse=payload.get("top_k_sparse"),
|
|
top_m_final=payload.get("top_m_final"),
|
|
)
|
|
return kb_helper.kb.model_dump(), "创建知识库成功"
|
|
|
|
async def get_kb(self, kb_id: str | None) -> dict[str, Any]:
|
|
if not kb_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
|
kb_helper = await self.get_kb_manager().get_kb(kb_id)
|
|
if not kb_helper:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
return kb_helper.kb.model_dump()
|
|
|
|
async def get_kb_from_dashboard_query(self, kb_id: str | None) -> dict[str, Any]:
|
|
return await self.get_kb(kb_id)
|
|
|
|
async def update_kb(self, data: object) -> tuple[dict[str, Any], str]:
|
|
payload = self._payload(data)
|
|
kb_id = payload.get("kb_id")
|
|
if not kb_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
|
|
|
update_keys = [
|
|
"kb_name",
|
|
"description",
|
|
"emoji",
|
|
"embedding_provider_id",
|
|
"rerank_provider_id",
|
|
"chunk_size",
|
|
"chunk_overlap",
|
|
"top_k_dense",
|
|
"top_k_sparse",
|
|
"top_m_final",
|
|
]
|
|
if all(payload.get(key) is None for key in update_keys):
|
|
raise KnowledgeBaseServiceError("至少需要提供一个更新字段")
|
|
|
|
current_kb = await self.get_kb_manager().get_kb(kb_id)
|
|
kb_name = payload.get("kb_name")
|
|
if kb_name is None:
|
|
if not current_kb:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
kb_name = current_kb.kb.kb_name
|
|
|
|
kb_helper = await self.get_kb_manager().update_kb(
|
|
kb_id=kb_id,
|
|
kb_name=kb_name,
|
|
description=payload.get("description"),
|
|
emoji=payload.get("emoji"),
|
|
embedding_provider_id=payload.get("embedding_provider_id"),
|
|
rerank_provider_id=payload.get("rerank_provider_id"),
|
|
chunk_size=payload.get("chunk_size"),
|
|
chunk_overlap=payload.get("chunk_overlap"),
|
|
top_k_dense=payload.get("top_k_dense"),
|
|
top_k_sparse=payload.get("top_k_sparse"),
|
|
top_m_final=payload.get("top_m_final"),
|
|
)
|
|
if not kb_helper:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
return kb_helper.kb.model_dump(), "更新知识库成功"
|
|
|
|
async def delete_kb(self, data: object) -> tuple[None, str]:
|
|
payload = self._payload(data)
|
|
kb_id = payload.get("kb_id")
|
|
if not kb_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
|
success = await self.get_kb_manager().delete_kb(kb_id)
|
|
if not success:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
return None, "删除知识库成功"
|
|
|
|
async def get_kb_stats(self, kb_id: str | None) -> dict[str, Any]:
|
|
if not kb_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
|
kb_helper = await self.get_kb_manager().get_kb(kb_id)
|
|
if not kb_helper:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
kb = kb_helper.kb
|
|
return {
|
|
"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(),
|
|
}
|
|
|
|
async def get_kb_stats_from_dashboard_query(
|
|
self,
|
|
kb_id: str | None,
|
|
) -> dict[str, Any]:
|
|
return await self.get_kb_stats(kb_id)
|
|
|
|
async def list_documents(
|
|
self,
|
|
*,
|
|
kb_id: str | None,
|
|
page: int,
|
|
page_size: int,
|
|
) -> dict[str, Any]:
|
|
if not kb_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
|
kb_helper = await self.get_kb_manager().get_kb(kb_id)
|
|
if not kb_helper:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
|
|
offset = (page - 1) * page_size
|
|
doc_list = await kb_helper.list_documents(offset=offset, limit=page_size)
|
|
return {
|
|
"items": [doc.model_dump() for doc in doc_list],
|
|
"page": page,
|
|
"page_size": page_size,
|
|
}
|
|
|
|
async def list_documents_from_dashboard_query(
|
|
self,
|
|
*,
|
|
kb_id: str | None,
|
|
page,
|
|
page_size,
|
|
) -> dict[str, Any]:
|
|
return await self.list_documents(
|
|
kb_id=kb_id,
|
|
page=self._to_int(page, 1),
|
|
page_size=self._to_int(page_size, 100),
|
|
)
|
|
|
|
async def upload_document(
|
|
self,
|
|
*,
|
|
content_type: str | None,
|
|
form_data,
|
|
files,
|
|
) -> dict[str, Any]:
|
|
if content_type and "multipart/form-data" not in content_type:
|
|
raise KnowledgeBaseServiceError("Content-Type 须为 multipart/form-data")
|
|
|
|
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:
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
|
|
|
file_list = []
|
|
for key in files.keys():
|
|
if key == "file" or key.startswith("file") or key == "files[]":
|
|
file_list.extend(files.getlist(key))
|
|
if not file_list:
|
|
raise KnowledgeBaseServiceError("缺少文件")
|
|
if len(file_list) > 10:
|
|
raise KnowledgeBaseServiceError("最多只能上传10个文件")
|
|
|
|
files_to_upload = []
|
|
for file in file_list:
|
|
file_name = file.filename
|
|
temp_file_path = (
|
|
Path(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 file_obj:
|
|
file_content = await file_obj.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:
|
|
temp_file_path.unlink(missing_ok=True)
|
|
|
|
kb_helper = await self.get_kb_manager().get_kb(kb_id)
|
|
if not kb_helper:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
|
|
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 {
|
|
"task_id": task_id,
|
|
"file_count": len(files_to_upload),
|
|
"message": "task created, processing in background",
|
|
}
|
|
|
|
@staticmethod
|
|
def validate_import_request(data: dict[str, Any]):
|
|
kb_id = data.get("kb_id")
|
|
if not kb_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
|
|
|
documents = data.get("documents")
|
|
if not documents or not isinstance(documents, list):
|
|
raise KnowledgeBaseServiceError("缺少参数 documents 或格式错误")
|
|
|
|
for doc in documents:
|
|
if (
|
|
not isinstance(doc, dict)
|
|
or "file_name" not in doc
|
|
or "chunks" not in doc
|
|
):
|
|
raise KnowledgeBaseServiceError(
|
|
"文档格式错误,必须包含 file_name 和 chunks"
|
|
)
|
|
if not isinstance(doc["chunks"], list):
|
|
raise KnowledgeBaseServiceError("chunks 必须是列表")
|
|
if not all(
|
|
isinstance(chunk, str) and chunk.strip() for chunk in doc["chunks"]
|
|
):
|
|
raise KnowledgeBaseServiceError("chunks 必须是非空字符串列表")
|
|
|
|
return (
|
|
kb_id,
|
|
documents,
|
|
data.get("batch_size", 32),
|
|
data.get("tasks_limit", 3),
|
|
data.get("max_retries", 3),
|
|
)
|
|
|
|
async def import_documents(self, data: object) -> dict[str, Any]:
|
|
payload = self._payload(data)
|
|
kb_id, documents, batch_size, tasks_limit, max_retries = (
|
|
self.validate_import_request(payload)
|
|
)
|
|
|
|
kb_helper = await self.get_kb_manager().get_kb(kb_id)
|
|
if not kb_helper:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
|
|
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 {
|
|
"task_id": task_id,
|
|
"doc_count": len(documents),
|
|
"message": "import task created, processing in background",
|
|
}
|
|
|
|
def get_upload_progress(self, task_id: str | None) -> dict[str, Any]:
|
|
if not task_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 task_id")
|
|
if task_id not in self.upload_tasks:
|
|
raise KnowledgeBaseServiceError("找不到该任务")
|
|
|
|
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"]
|
|
if status == "failed":
|
|
response_data["error"] = task_info["error"]
|
|
return response_data
|
|
|
|
def get_upload_progress_from_dashboard_query(
|
|
self,
|
|
task_id: str | None,
|
|
) -> dict[str, Any]:
|
|
return self.get_upload_progress(task_id)
|
|
|
|
async def get_document(
|
|
self,
|
|
*,
|
|
kb_id: str | None,
|
|
doc_id: str | None,
|
|
) -> dict[str, Any]:
|
|
if not kb_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
|
if not doc_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 doc_id")
|
|
kb_helper = await self.get_kb_manager().get_kb(kb_id)
|
|
if not kb_helper:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
doc = await kb_helper.get_document(doc_id)
|
|
if not doc:
|
|
raise KnowledgeBaseServiceError("文档不存在")
|
|
return doc.model_dump()
|
|
|
|
async def get_document_from_dashboard_query(
|
|
self,
|
|
*,
|
|
kb_id: str | None,
|
|
doc_id: str | None,
|
|
) -> dict[str, Any]:
|
|
return await self.get_document(kb_id=kb_id, doc_id=doc_id)
|
|
|
|
async def delete_document(self, data: object) -> tuple[None, str]:
|
|
payload = self._payload(data)
|
|
kb_id = payload.get("kb_id")
|
|
doc_id = payload.get("doc_id")
|
|
if not kb_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
|
if not doc_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 doc_id")
|
|
kb_helper = await self.get_kb_manager().get_kb(kb_id)
|
|
if not kb_helper:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
await kb_helper.delete_document(doc_id)
|
|
return None, "删除文档成功"
|
|
|
|
async def delete_chunk(self, data: object) -> tuple[None, str]:
|
|
payload = self._payload(data)
|
|
kb_id = payload.get("kb_id")
|
|
chunk_id = payload.get("chunk_id")
|
|
doc_id = payload.get("doc_id")
|
|
if not kb_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
|
if not chunk_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 chunk_id")
|
|
if not doc_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 doc_id")
|
|
kb_helper = await self.get_kb_manager().get_kb(kb_id)
|
|
if not kb_helper:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
await kb_helper.delete_chunk(chunk_id, doc_id)
|
|
return None, "删除文本块成功"
|
|
|
|
async def list_chunks(
|
|
self,
|
|
*,
|
|
kb_id: str | None,
|
|
doc_id: str | None,
|
|
page: int,
|
|
page_size: int,
|
|
) -> dict[str, Any]:
|
|
if not kb_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
|
if not doc_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 doc_id")
|
|
kb_helper = await self.get_kb_manager().get_kb(kb_id)
|
|
if not kb_helper:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
|
|
offset = (page - 1) * page_size
|
|
return {
|
|
"items": await kb_helper.get_chunks_by_doc_id(
|
|
doc_id=doc_id,
|
|
offset=offset,
|
|
limit=page_size,
|
|
),
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"total": await kb_helper.get_chunk_count_by_doc_id(doc_id),
|
|
}
|
|
|
|
async def list_chunks_from_dashboard_query(
|
|
self,
|
|
*,
|
|
kb_id: str | None,
|
|
doc_id: str | None,
|
|
page,
|
|
page_size,
|
|
) -> dict[str, Any]:
|
|
return await self.list_chunks(
|
|
kb_id=kb_id,
|
|
doc_id=doc_id,
|
|
page=self._to_int(page, 1),
|
|
page_size=self._to_int(page_size, 100),
|
|
)
|
|
|
|
async def retrieve(self, data: object) -> dict[str, Any]:
|
|
payload = self._payload(data)
|
|
query = payload.get("query")
|
|
kb_names = payload.get("kb_names")
|
|
debug = payload.get("debug", False)
|
|
|
|
if not query:
|
|
raise KnowledgeBaseServiceError("缺少参数 query")
|
|
if not kb_names or not isinstance(kb_names, list):
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_names 或格式错误")
|
|
|
|
top_k = payload.get("top_k", 5)
|
|
kb_manager = self.get_kb_manager()
|
|
results = await kb_manager.retrieve(
|
|
query=query,
|
|
kb_names=kb_names,
|
|
top_m_final=top_k,
|
|
)
|
|
result_list = results["results"] if results else []
|
|
response_data = {
|
|
"results": result_list,
|
|
"total": len(result_list),
|
|
"query": query,
|
|
}
|
|
|
|
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 exc:
|
|
logger.error(f"生成 t-SNE 可视化失败: {exc}")
|
|
logger.error(traceback.format_exc())
|
|
response_data["visualization_error"] = str(exc)
|
|
|
|
return response_data
|
|
|
|
async def upload_document_from_url(self, data: object) -> dict[str, Any]:
|
|
payload = self._payload(data)
|
|
kb_id = payload.get("kb_id")
|
|
if not kb_id:
|
|
raise KnowledgeBaseServiceError("缺少参数 kb_id")
|
|
url = payload.get("url")
|
|
if not url:
|
|
raise KnowledgeBaseServiceError("缺少参数 url")
|
|
|
|
kb_helper = await self.get_kb_manager().get_kb(kb_id)
|
|
if not kb_helper:
|
|
raise KnowledgeBaseServiceError("知识库不存在")
|
|
|
|
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=payload.get("chunk_size", 512),
|
|
chunk_overlap=payload.get("chunk_overlap", 50),
|
|
batch_size=payload.get("batch_size", 32),
|
|
tasks_limit=payload.get("tasks_limit", 3),
|
|
max_retries=payload.get("max_retries", 3),
|
|
enable_cleaning=payload.get("enable_cleaning", False),
|
|
cleaning_provider_id=payload.get("cleaning_provider_id"),
|
|
),
|
|
)
|
|
return {
|
|
"task_id": task_id,
|
|
"url": url,
|
|
"message": "URL upload task created, processing in background",
|
|
}
|
|
|
|
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:
|
|
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,
|
|
)
|
|
self.set_task_result(
|
|
task_id,
|
|
"completed",
|
|
result={
|
|
"task_id": task_id,
|
|
"uploaded": [doc.model_dump()],
|
|
"failed": [],
|
|
"total": 1,
|
|
"success_count": 1,
|
|
"failed_count": 0,
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
logger.error(f"后台上传URL任务 {task_id} 失败: {exc}")
|
|
logger.error(traceback.format_exc())
|
|
self.set_task_result(task_id, "failed", error=str(exc))
|
|
|
|
@staticmethod
|
|
def _to_int(value, default: int) -> int:
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
__all__ = ["KnowledgeBaseService", "KnowledgeBaseServiceError"]
|