Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

2059 lines
82 KiB
Python
Raw Permalink 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.
import asyncio
import os
import textwrap
import time
import traceback
from urllib.parse import quote, unquote
from fastapi import APIRouter, Body, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from starlette.responses import StreamingResponse
from yuxi import config, knowledge_base
from yuxi.knowledge.chunking.ragflow_like.presets import get_chunk_preset_options
from yuxi.knowledge.factory import KnowledgeBaseFactory
from yuxi.knowledge.graphs.milvus_graph_service import GRAPH_TASK_TYPE, MilvusGraphService
from yuxi.knowledge.parser import SUPPORTED_FILE_EXTENSIONS, Parser, is_supported_file_extension
from yuxi.knowledge.utils import calculate_content_hash, is_minio_url, parse_minio_url
from yuxi.knowledge.utils.mindmap_utils import (
batch_remove_files_from_mindmap,
generate_database_mindmap,
get_database_mindmap_data,
get_mindmap_database_files,
get_mindmap_databases_overview,
get_mindmap_diff,
remove_file_from_mindmap,
)
from yuxi.knowledge.utils.sample_question_utils import (
generate_database_sample_questions,
get_database_sample_questions,
)
from yuxi.knowledge.utils.url_fetcher import fetch_url_content
from yuxi.models.providers.cache import model_cache
from yuxi.services.task_service import TaskContext, tasker
from yuxi.services.workspace_service import MAX_WORKSPACE_UPLOAD_SIZE_BYTES, resolve_workspace_file_path
from yuxi.storage.minio.client import MinIOClient, StorageError, aupload_file_to_minio, get_minio_client
from yuxi.storage.postgres.models_business import User
from yuxi.utils import logger
from yuxi.utils.upload_utils import MAX_UPLOAD_SIZE_BYTES, read_upload_with_limit, write_upload_to_path
from server.utils.auth_middleware import get_admin_user, get_required_user
knowledge = APIRouter(prefix="/knowledge", tags=["knowledge"])
ACTIVE_GRAPH_BUILD_STATUSES = {"pending", "running"}
ACTIVE_DOCUMENT_ACTION_TASK_STATUSES = {"pending", "running"}
DOCUMENT_ACTION_BATCH_SIZE = 500
DOCUMENT_ACTION_RESULT_ITEM_LIMIT = 200
MAX_DIRECT_DOCUMENT_ACTION_FILE_IDS = 1000
PENDING_PARSE_STATUSES = ["uploaded"]
PENDING_INDEX_STATUSES = ["parsed", "error_indexing"]
class UpdateDatabaseRequest(BaseModel):
name: str
description: str
llm_model_spec: str | None = None
additional_params: dict | None = None
share_config: dict | None = None
class WorkspaceImportRequest(BaseModel):
kb_id: str
paths: list[str]
class AddUploadedDocumentsRequest(BaseModel):
items: list[str]
params: dict | None = None
class PendingIndexDocumentsRequest(BaseModel):
params: dict | None = None
media_types = {
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt": "text/plain",
".md": "text/markdown",
".json": "application/json",
".csv": "text/csv",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xls": "application/vnd.ms-excel",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".ppt": "application/vnd.ms-powerpoint",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".zip": "application/zip",
".rar": "application/x-rar-compressed",
".7z": "application/x-7z-compressed",
".tar": "application/x-tar",
".gz": "application/gzip",
".html": "text/html",
".htm": "text/html",
".xml": "text/xml",
".css": "text/css",
".js": "application/javascript",
".py": "text/x-python",
".java": "text/x-java-source",
".cpp": "text/x-c++src",
".c": "text/x-csrc",
".h": "text/x-chdr",
".hpp": "text/x-c++hdr",
}
async def _delete_document_storage_objects(kb_id: str, doc_id: str, file_path: str) -> None:
minio_client = get_minio_client()
if is_minio_url(file_path):
try:
bucket_name, object_name = parse_minio_url(file_path)
await minio_client.adelete_file(bucket_name, object_name)
except Exception as minio_error:
logger.warning(f"从MinIO删除原始文件失败: {minio_error}")
try:
await minio_client.adelete_file(minio_client.KB_BUCKETS["parsed"], f"{kb_id}/parsed/{doc_id}.md")
except Exception as minio_error:
logger.warning(f"从MinIO删除解析结果失败: {minio_error}")
try:
await minio_client.adelete_file(minio_client.KB_BUCKETS["parsed"], f"{kb_id}/preview/{doc_id}.pdf")
except Exception as minio_error:
logger.warning(f"从MinIO删除预览 PDF 失败: {minio_error}")
async def _ensure_database_supports_documents(kb_id: str, operation: str) -> None:
db_info = await knowledge_base.get_database_info(kb_id)
if not db_info:
raise HTTPException(status_code=404, detail=f"知识库 {kb_id} 不存在")
kb_type = (db_info.get("kb_type") or "").lower()
kb_class = KnowledgeBaseFactory.get_kb_class(kb_type)
if not kb_class.supports_documents:
raise HTTPException(status_code=400, detail=f"{db_info.get('name') or kb_type} 只支持检索,不支持{operation}")
def _ensure_document_params(params: dict | None) -> dict:
if params is None:
return {}
if not isinstance(params, dict):
raise HTTPException(status_code=400, detail="params must be an object")
return params
def _validate_uploaded_document_items(items: list[str], params: dict) -> None:
if not items:
raise HTTPException(status_code=400, detail="items must not be empty")
content_hashes = params.get("content_hashes")
if content_hashes is not None and not isinstance(content_hashes, dict):
raise HTTPException(status_code=400, detail="params.content_hashes must be an object")
file_sizes = params.get("file_sizes")
if file_sizes is not None and not isinstance(file_sizes, dict):
raise HTTPException(status_code=400, detail="params.file_sizes must be an object")
preprocessed_map = params.get("_preprocessed_map")
if preprocessed_map is not None and not isinstance(preprocessed_map, dict):
raise HTTPException(status_code=400, detail="params._preprocessed_map must be an object")
for item in items:
if not isinstance(item, str) or not item.strip():
raise HTTPException(status_code=400, detail="items must only contain non-empty strings")
if not is_minio_url(item):
raise HTTPException(status_code=400, detail="File source must be a MinIO URL")
has_content_hash = isinstance(content_hashes, dict) and bool(content_hashes.get(item))
preprocessed = preprocessed_map.get(item) if isinstance(preprocessed_map, dict) else None
has_preprocessed_hash = isinstance(preprocessed, dict) and bool(preprocessed.get("content_hash"))
if not has_content_hash and not has_preprocessed_hash:
raise HTTPException(status_code=400, detail=f"Missing content_hash for file: {item}")
def _params_for_uploaded_document_item(item: str, params: dict) -> dict:
source_paths = params.get("source_paths")
item_params = dict(params)
item_params.pop("source_paths", None)
if isinstance(source_paths, dict) and source_paths.get(item):
item_params["source_path"] = source_paths[item]
return item_params
async def _has_running_graph_build_task(kb_id: str) -> bool:
return (
await tasker.find_task_by_payload(
task_type=GRAPH_TASK_TYPE,
payload_match={"kb_id": kb_id},
statuses=ACTIVE_GRAPH_BUILD_STATUSES,
)
is not None
)
# =============================================================================
# === 知识库管理分组 ===
# =============================================================================
@knowledge.get("/databases")
async def get_databases(current_user: User = Depends(get_admin_user)):
"""获取所有知识库(根据用户权限过滤)"""
try:
return await knowledge_base.get_databases_by_uid(current_user.uid)
except Exception as e:
logger.error(f"获取数据库列表失败 {e}, {traceback.format_exc()}")
return {"message": f"获取数据库列表失败 {e}", "databases": []}
@knowledge.post("/databases")
async def create_database(
database_name: str = Body(...),
description: str = Body(...),
embedding_model_spec: str | None = Body(None),
kb_type: str = Body("milvus"),
additional_params: dict | None = Body(None),
llm_model_spec: str | None = Body(None),
share_config: dict | None = Body(None),
current_user: User = Depends(get_admin_user),
):
"""创建知识库"""
logger.debug(
f"Create database {database_name} with kb_type {kb_type}, "
f"additional_params {additional_params}, llm_model_spec {llm_model_spec}, "
f"embedding_model_spec {embedding_model_spec}, share_config {share_config}"
)
try:
# 先检查名称是否已存在
if await knowledge_base.database_name_exists(database_name):
raise HTTPException(
status_code=409,
detail=f"知识库名称 '{database_name}' 已存在,请使用其他名称",
)
if not KnowledgeBaseFactory.is_type_supported(kb_type):
raise HTTPException(status_code=400, detail=f"Unsupported knowledge base type: {kb_type}")
kb_class = KnowledgeBaseFactory.get_kb_class(kb_type)
additional_params = {**(additional_params or {})}
additional_params["auto_generate_questions"] = False # 默认不生成问题
if "reranker_config" in additional_params:
raise HTTPException(
status_code=400,
detail="reranker_config 已移除,请在查询参数中使用 reranker_model spec",
)
additional_params = kb_class.normalize_additional_params(additional_params)
if kb_class.requires_embedding_model:
if not embedding_model_spec:
raise HTTPException(status_code=400, detail="embedding_model_spec 不能为空")
info = model_cache.get_model_info(embedding_model_spec)
if not info or info.model_type != "embedding":
raise HTTPException(status_code=400, detail=f"不支持的 embedding 模型: {embedding_model_spec}")
else:
embedding_model_spec = None
database_info = await knowledge_base.create_database(
database_name,
description,
kb_type=kb_type,
embedding_model_spec=embedding_model_spec,
llm_model_spec=llm_model_spec,
share_config=share_config,
created_by=current_user.uid,
created_by_department_id=current_user.department_id,
**additional_params,
)
# 需要重新加载所有智能体,因为工具刷新了
from yuxi.agents.buildin import agent_manager
await agent_manager.reload_all()
return database_info
except HTTPException:
raise
except Exception as e:
logger.error(f"创建数据库失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=400, detail=f"创建数据库失败: {e}")
@knowledge.get("/databases/accessible")
async def get_accessible_databases(current_user: User = Depends(get_required_user)):
"""获取当前用户有权访问的知识库列表(用于智能体配置)"""
try:
databases = await knowledge_base.get_databases_by_uid(current_user.uid)
accessible = [
{
"name": db.get("name", ""),
"kb_id": db.get("kb_id"),
"description": db.get("description", ""),
"created_by": db.get("created_by"),
"kb_type": db.get("kb_type"),
"supports_documents": KnowledgeBaseFactory.get_kb_class(
(db.get("kb_type") or "milvus").lower()
).supports_documents,
}
for db in databases.get("databases", [])
]
return {"databases": accessible}
except Exception as e:
logger.error(f"获取可访问知识库列表失败: {e}, {traceback.format_exc()}")
return {"message": f"获取可访问知识库列表失败: {str(e)}", "databases": []}
@knowledge.get("/mindmap/databases")
async def get_mindmap_databases(current_user: User = Depends(get_admin_user)):
"""获取所有知识库的概览信息,用于思维导图界面选择。"""
try:
return await get_mindmap_databases_overview(current_user.uid)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取知识库列表失败: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"获取知识库列表失败: {str(e)}")
@knowledge.get("/databases/{kb_id}/mindmap/files")
async def get_database_mindmap_files(kb_id: str, current_user: User = Depends(get_admin_user)):
"""获取指定知识库的所有文件列表。"""
try:
return await get_mindmap_database_files(kb_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取文件列表失败: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"获取文件列表失败: {str(e)}")
@knowledge.post("/databases/{kb_id}/mindmap/generate")
async def generate_mindmap(
kb_id: str,
file_ids: list[str] | None = Body(default=None, description="选择的文件ID列表"),
user_prompt: str = Body(default="", description="用户自定义提示词"),
incremental: bool = Body(default=False, description="是否增量更新"),
current_user: User = Depends(get_admin_user),
):
"""使用 AI 分析知识库文件,生成思维导图结构。支持增量更新模式。"""
try:
return await generate_database_mindmap(kb_id, file_ids, user_prompt, incremental)
except HTTPException:
raise
except Exception as e:
logger.error(f"生成思维导图失败: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"生成思维导图失败: {str(e)}")
@knowledge.get("/databases/{kb_id}/mindmap")
async def get_database_mindmap(kb_id: str, current_user: User = Depends(get_admin_user)):
"""获取知识库关联的思维导图。"""
try:
return await get_database_mindmap_data(kb_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取知识库思维导图失败: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"获取知识库思维导图失败: {str(e)}")
@knowledge.get("/databases/{kb_id}/mindmap/diff")
async def get_mindmap_diff_route(kb_id: str, current_user: User = Depends(get_admin_user)):
"""检测思维导图与知识库文件的变更差异。"""
try:
return await get_mindmap_diff(kb_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"检测思维导图变更失败: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"检测思维导图变更失败: {str(e)}")
@knowledge.get("/databases/{kb_id}")
async def get_database_info(
kb_id: str,
include_files: bool = Query(False, description="是否包含全量文件列表,默认关闭以避免大知识库响应过大"),
current_user: User = Depends(get_admin_user),
):
"""获取知识库详细信息"""
database = await knowledge_base.get_database_info(kb_id, include_files=include_files)
if database is None:
raise HTTPException(status_code=404, detail="Database not found")
return database
@knowledge.post("/databases/{kb_id}/stats/repair")
async def repair_database_stats(kb_id: str, current_user: User = Depends(get_admin_user)):
"""修复知识库历史文件缺失的 Chunk/Token 统计。"""
await _ensure_database_supports_documents(kb_id, "统计修复")
try:
return await knowledge_base.repair_missing_file_stats(kb_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"修复知识库统计失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"修复知识库统计失败: {e}")
@knowledge.put("/databases/{kb_id}")
async def update_database_info(
kb_id: str,
data: UpdateDatabaseRequest,
current_user: User = Depends(get_admin_user),
):
"""更新知识库信息"""
logger.debug(
f"[update_database_info] 接收到的参数: name={data.name}, llm_model_spec={data.llm_model_spec}, "
f"additional_params={data.additional_params}, share_config={data.share_config}"
)
try:
update_llm_model_spec = "llm_model_spec" in data.model_fields_set
additional_params = data.additional_params
if additional_params is not None:
db_info = await knowledge_base.get_database_info(kb_id)
if not db_info:
raise HTTPException(status_code=404, detail=f"知识库 {kb_id} 不存在")
kb_type = (db_info.get("kb_type") or "").lower()
kb_class = KnowledgeBaseFactory.get_kb_class(kb_type)
merged_params = dict(db_info.get("additional_params") or {})
merged_params.update(additional_params)
kb_class.normalize_additional_params(merged_params)
additional_params = (
kb_class.normalize_additional_params(additional_params)
if kb_class.apply_chunk_defaults
else kb_class.normalize_additional_params(merged_params)
)
database = await knowledge_base.update_database(
kb_id,
data.name,
data.description,
data.llm_model_spec,
update_llm_model_spec=update_llm_model_spec,
additional_params=additional_params,
share_config=data.share_config,
operator_uid=current_user.uid,
operator_department_id=current_user.department_id,
)
return {"message": "更新成功", "database": database}
except HTTPException:
raise
except Exception as e:
logger.error(f"更新数据库失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=400, detail=f"更新数据库失败: {e}")
@knowledge.delete("/databases/{kb_id}")
async def delete_database(kb_id: str, current_user: User = Depends(get_admin_user)):
"""删除知识库"""
logger.debug(f"Delete database {kb_id}")
try:
await knowledge_base.delete_database(kb_id)
# 需要重新加载所有智能体,因为工具刷新了
from yuxi.agents.buildin import agent_manager
await agent_manager.reload_all()
return {"message": "删除成功"}
except Exception as e:
logger.error(f"删除数据库失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=400, detail=f"删除数据库失败: {e}")
@knowledge.get("/databases/{kb_id}/graph-build/status")
async def get_graph_build_status(kb_id: str, current_user: User = Depends(get_admin_user)):
try:
return await MilvusGraphService().get_status(kb_id, tasker=tasker)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"获取图谱构建状态失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"获取图谱构建状态失败: {e}")
@knowledge.post("/databases/{kb_id}/graph-build/config")
async def configure_graph_build(
kb_id: str,
data: dict = Body(...),
current_user: User = Depends(get_admin_user),
):
try:
config = await MilvusGraphService().configure(
kb_id,
extractor_type=data.get("extractor_type"),
extractor_options=data.get("extractor_options") or {},
created_by=current_user.uid,
)
return {"message": "图谱抽取配置已锁定", "status": "success", "config": config}
except ValueError as e:
status_code = 409 if "已锁定" in str(e) else 400
raise HTTPException(status_code=status_code, detail=str(e))
except Exception as e:
logger.error(f"配置图谱构建失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"配置图谱构建失败: {e}")
@knowledge.post("/databases/{kb_id}/graph-build/index")
async def index_graph_build(
kb_id: str,
data: dict | None = Body(default=None),
current_user: User = Depends(get_admin_user),
):
data = data or {}
try:
if await _has_running_graph_build_task(kb_id):
raise HTTPException(status_code=409, detail="该知识库已有正在运行的图谱构建任务")
database = await knowledge_base.get_database_info(kb_id)
if not database:
raise HTTPException(status_code=404, detail=f"知识库 {kb_id} 不存在")
batch_size = max(1, min(int(data.get("batch_size") or 20), 200))
service = MilvusGraphService()
graph_status = await service.get_status(kb_id)
if not graph_status.get("locked"):
raise HTTPException(status_code=400, detail="请先确认并锁定图谱抽取配置")
async def run_graph_index(context: TaskContext):
await context.set_message("任务初始化")
await context.set_progress(5.0, "准备构建图谱")
result = await service.build_pending_chunks(kb_id, batch_size=batch_size, context=context)
await context.set_result(result)
await context.set_progress(100.0, f"图谱构建完成,成功 {result['success']} 个,失败 {result['failed']} 个")
return result
task, created = await tasker.enqueue_unique_by_payload(
name=f"图谱构建 ({database['name']})",
task_type=GRAPH_TASK_TYPE,
payload={"kb_id": kb_id, "batch_size": batch_size},
coroutine=run_graph_index,
payload_match={"kb_id": kb_id},
statuses=ACTIVE_GRAPH_BUILD_STATUSES,
)
if not created:
raise HTTPException(status_code=409, detail="该知识库已有正在运行的图谱构建任务")
return {"message": "图谱构建任务已提交", "status": "queued", "task_id": task.id}
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"提交图谱构建任务失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"提交图谱构建任务失败: {e}")
@knowledge.post("/databases/{kb_id}/graph-build/reset")
async def reset_graph_build(
kb_id: str,
data: dict | None = Body(default=None),
current_user: User = Depends(get_admin_user),
):
data = data or {}
try:
if await _has_running_graph_build_task(kb_id):
raise HTTPException(status_code=409, detail="该知识库存在正在运行的图谱构建任务,无法重置")
return await MilvusGraphService().reset(
kb_id,
clear_extraction_result=bool(data.get("clear_extraction_result", True)),
clear_config=bool(data.get("clear_config", False)),
)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"重置图谱构建状态失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"重置图谱构建状态失败: {e}")
@knowledge.get("/databases/{kb_id}/export")
async def export_database(
kb_id: str,
format: str = Query("csv", enum=["csv", "xlsx", "md", "txt"]),
include_vectors: bool = Query(False, description="是否在导出中包含向量数据"),
current_user: User = Depends(get_admin_user),
):
"""导出知识库数据"""
logger.debug(f"Exporting database {kb_id} with format {format}")
try:
file_path = await knowledge_base.export_data(kb_id, format=format, include_vectors=include_vectors)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Exported file not found.")
media_type = media_types.get(f".{format}", "application/octet-stream")
return FileResponse(path=file_path, filename=os.path.basename(file_path), media_type=media_type)
except HTTPException:
raise
except NotImplementedError as e:
logger.warning(f"A disabled feature was accessed: {e}")
raise HTTPException(status_code=501, detail=str(e))
except Exception as e:
logger.error(f"导出数据库失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"导出数据库失败: {e}")
# =============================================================================
# === 知识库文档管理分组 ===
# =============================================================================
@knowledge.get("/databases/{kb_id}/documents")
async def list_documents(
kb_id: str,
parent_id: str | None = Query(None, description="父文件夹 ID,空值表示根目录"),
path_prefix: str | None = Query(None, description="路径型目录前缀,用于懒加载 source_path 形成的虚拟目录"),
status: str = Query("all", description="文件状态筛选"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(100, ge=1, le=500, description="每页数量"),
recursive: bool = Query(False, description="是否跨目录筛选"),
current_user: User = Depends(get_admin_user),
):
"""分页获取知识库文件列表。"""
await _ensure_database_supports_documents(kb_id, "文档查看")
try:
return await knowledge_base.list_document_files(
kb_id,
parent_id=parent_id,
path_prefix=path_prefix,
status=status,
page=page,
page_size=page_size,
recursive=recursive,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@knowledge.get("/databases/{kb_id}/documents/exists")
async def document_file_exists(
kb_id: str,
filename: str = Query(..., min_length=1, description="知识库文件展示名或相对路径"),
current_user: User = Depends(get_admin_user),
):
"""检查知识库中是否已存在指定文件名或相对路径的文件。"""
await _ensure_database_supports_documents(kb_id, "文档存在性检查")
normalized_filename = filename.strip()
if not normalized_filename:
raise HTTPException(status_code=400, detail="filename is required")
try:
exists = await knowledge_base.document_file_exists(kb_id, normalized_filename)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"kb_id": kb_id, "filename": normalized_filename, "exists": exists}
@knowledge.post("/databases/{kb_id}/documents")
async def add_documents(
kb_id: str, items: list[str] = Body(...), params: dict = Body(...), current_user: User = Depends(get_admin_user)
):
"""添加文档到知识库(上传 -> 解析 -> 可选入库)"""
logger.debug(f"Add documents for kb_id {kb_id}: {items} {params=}")
await _ensure_database_supports_documents(kb_id, "文档添加/解析/入库")
params = _ensure_document_params(params)
content_type = params.get("content_type", "file")
# 自动入库参数
auto_index = params.get("auto_index", False)
indexing_params = {}
chunk_preset_id = params.get("chunk_preset_id")
if chunk_preset_id:
indexing_params["chunk_preset_id"] = chunk_preset_id
chunk_parser_config = params.get("chunk_parser_config")
if isinstance(chunk_parser_config, dict):
indexing_params["chunk_parser_config"] = chunk_parser_config
if content_type == "url":
raise HTTPException(status_code=400, detail="URL 处理方式已变更,请使用 fetch-url 接口先获取内容")
if content_type != "file":
raise HTTPException(status_code=400, detail=f"Unsupported content_type: {content_type}")
_validate_uploaded_document_items(items, params)
async def run_ingest(context: TaskContext):
await context.set_message("任务初始化")
await context.set_progress(5.0, "准备处理文档")
total = len(items)
processed_items: list[dict | None] = [None] * total
added_files: list[dict] = []
try:
await context.set_message("第一阶段:添加文件记录")
for idx, item in enumerate(items, 1):
await context.raise_if_cancelled()
progress = 5.0 + (idx / total) * 25.0
await context.set_progress(progress, f"[1/3] 添加记录 {idx}/{total}")
try:
file_meta = await knowledge_base.add_file_record(
kb_id, item, params=params, operator_id=current_user.uid
)
added_files.append(
{
"index": idx - 1,
"item": item,
"file_id": file_meta["file_id"],
"file_meta": file_meta,
}
)
except Exception as add_error:
logger.error(f"添加文件记录失败 {item}: {add_error}")
error_type = "timeout" if isinstance(add_error, TimeoutError) else "add_failed"
error_msg = "添加超时" if isinstance(add_error, TimeoutError) else "添加记录失败"
processed_items[idx - 1] = {
"item": item,
"status": "failed",
"error": f"{error_msg}: {str(add_error)}",
"error_type": error_type,
}
await context.set_message("第二阶段:解析文件")
parse_end = 60.0 if auto_index else 95.0
parse_total = len(added_files)
for idx, record in enumerate(added_files, 1):
await context.raise_if_cancelled()
progress = 30.0 + (idx / parse_total) * (parse_end - 30.0)
await context.set_progress(progress, f"[2/3] 解析文件 {idx}/{parse_total}")
item = record["item"]
file_id = record["file_id"]
try:
file_meta = await knowledge_base.parse_file(kb_id, file_id, operator_id=current_user.uid)
record["file_meta"] = file_meta
if not auto_index or file_meta.get("status") != "parsed":
processed_items[record["index"]] = file_meta
except Exception as parse_error:
logger.error(f"解析文件失败 {item} (file_id={file_id}): {parse_error}")
error_type = "timeout" if isinstance(parse_error, TimeoutError) else "parse_failed"
error_msg = "解析超时" if isinstance(parse_error, TimeoutError) else "解析失败"
processed_items[record["index"]] = {
"item": item,
"status": "failed",
"error": f"{error_msg}: {str(parse_error)}",
"error_type": error_type,
}
if auto_index:
await context.set_message("第三阶段:自动入库")
parsed_files = [record for record in added_files if record["file_meta"].get("status") == "parsed"]
total_parsed = len(parsed_files)
for idx, record in enumerate(parsed_files, 1):
await context.raise_if_cancelled()
progress = 60.0 + (idx / total_parsed) * 35.0
await context.set_progress(progress, f"[3/3] 入库文件 {idx}/{total_parsed}")
item = record["item"]
file_id = record["file_id"]
try:
await knowledge_base.update_file_params(
kb_id, file_id, indexing_params, operator_id=current_user.uid
)
result = await knowledge_base.index_file(
kb_id, file_id, operator_id=current_user.uid, params=indexing_params
)
processed_items[record["index"]] = result
except Exception as index_error:
logger.error(f"自动入库失败 {item} (file_id={file_id}): {index_error}")
processed_items[record["index"]] = {
"item": item,
"status": "failed",
"error": f"入库失败: {str(index_error)}",
"error_type": "index_failed",
}
except asyncio.CancelledError:
await context.set_progress(100.0, "任务已取消")
raise
except Exception as task_error:
logger.exception(f"Task processing failed: {task_error}")
await context.set_progress(100.0, f"任务处理失败: {str(task_error)}")
raise
final_items = [
item
if item is not None
else {
"item": items[index],
"status": "failed",
"error": "文件未处理",
"error_type": "not_processed",
}
for index, item in enumerate(processed_items)
]
failed_count = len([item for item in final_items if _is_failed_item(item)])
summary = {
"kb_id": kb_id,
"item_type": "文件",
"submitted": total,
"failed": failed_count,
}
message = f"文件处理完成,失败 {failed_count} 个" if failed_count else "文件处理完成"
await context.set_result(summary | {"items": final_items})
await context.set_progress(100.0, message)
if failed_count:
raise RuntimeError(message)
return summary | {"items": final_items}
try:
database = await knowledge_base.get_database_info(kb_id)
task = await tasker.enqueue(
name=f"知识库文档处理 ({database['name']})",
task_type="knowledge_ingest",
payload={
"kb_id": kb_id,
"items": items,
"params": params,
"content_type": content_type,
},
coroutine=run_ingest,
)
return {
"message": "任务已提交,请在任务中心查看进度",
"status": "queued",
"task_id": task.id,
}
except Exception as e: # noqa: BLE001
logger.error(f"Failed to enqueue {content_type}s: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"Failed to enqueue task: {e}")
@knowledge.post("/databases/{kb_id}/documents/add")
async def add_uploaded_documents(
kb_id: str,
payload: AddUploadedDocumentsRequest,
current_user: User = Depends(get_admin_user),
):
"""将已上传的 MinIO 文件同步添加为知识库文档记录,不解析、不入库。"""
logger.debug(f"Add uploaded documents for kb_id {kb_id}: {payload.items} params={payload.params}")
await _ensure_database_supports_documents(kb_id, "文档添加")
params = _ensure_document_params(payload.params)
content_type = params.get("content_type", "file")
if content_type == "url":
raise HTTPException(status_code=400, detail="URL 处理方式已变更,请使用 fetch-url 接口先获取内容")
if content_type != "file":
raise HTTPException(status_code=400, detail=f"Unsupported content_type: {content_type}")
_validate_uploaded_document_items(payload.items, params)
added_items: list[dict] = []
failed_items: list[dict] = []
for index, item in enumerate(payload.items):
try:
file_meta = await knowledge_base.add_file_record(
kb_id,
item,
params=_params_for_uploaded_document_item(item, params),
operator_id=current_user.uid,
)
added_items.append(
{
"index": index,
"item": item,
"file_id": file_meta["file_id"],
"status": file_meta.get("status"),
"file_meta": file_meta,
}
)
except Exception as add_error: # noqa: BLE001
logger.error(f"添加文件记录失败 {item}: {add_error}")
failed_items.append(
{
"index": index,
"item": item,
"status": "failed",
"error": f"添加记录失败: {str(add_error)}",
"error_type": "add_failed",
}
)
failed_count = len(failed_items)
added_count = len(added_items)
if failed_count == 0:
status = "success"
message = f"已添加 {added_count} 个文件"
elif added_count == 0:
status = "failed"
message = f"文件添加失败,失败 {failed_count} 个"
else:
status = "partial_failed"
message = f"已添加 {added_count} 个文件,失败 {failed_count} 个"
return {
"message": message,
"status": status,
"items": added_items,
"failed_items": failed_items,
"added": added_count,
"failed": failed_count,
}
def _validate_direct_document_action_file_ids(file_ids: list[str]) -> list[str]:
normalized_file_ids = [file_id for file_id in file_ids if file_id]
if not normalized_file_ids:
raise HTTPException(status_code=400, detail="请选择至少一个文件")
if len(normalized_file_ids) > MAX_DIRECT_DOCUMENT_ACTION_FILE_IDS:
raise HTTPException(
status_code=400,
detail=(f"单次最多支持 {MAX_DIRECT_DOCUMENT_ACTION_FILE_IDS} 个文件,请使用待处理状态入口提交全量后台任务"),
)
return normalized_file_ids
def _append_document_action_result_sample(items: list[dict], item: dict) -> None:
if len(items) < DOCUMENT_ACTION_RESULT_ITEM_LIMIT:
items.append(item)
def _is_failed_item(item: dict) -> bool:
"""判断单个处理结果是否失败:显式失败状态,或携带非空错误信息。
文件元数据成功时也会带 `error: None`,因此不能仅凭 `error` key 是否存在来判定失败。
"""
return item.get("status") == "failed" or bool(item.get("error"))
async def _run_parse_file_ids(
*,
context: TaskContext,
kb_id: str,
file_ids: list[str],
operator_id: str,
) -> dict:
await context.set_message("任务初始化")
await context.set_progress(5.0, "准备解析文档")
total = len(file_ids)
processed_items = []
for idx, file_id in enumerate(file_ids, 1):
await context.raise_if_cancelled()
progress = 5.0 + (idx / total) * 90.0
await context.set_progress(progress, f"正在解析第 {idx}/{total} 个文档")
try:
result = await knowledge_base.parse_file(kb_id, file_id, operator_id=operator_id)
processed_items.append(result)
except Exception as e:
logger.error(f"Parse failed for {file_id}: {e}")
processed_items.append({"file_id": file_id, "status": "failed", "error": str(e)})
failed_count = len([p for p in processed_items if _is_failed_item(p)])
message = f"解析完成,失败 {failed_count} 个"
result_payload = {"items": processed_items, "processed": len(processed_items), "failed": failed_count}
await context.set_result(result_payload)
await context.set_progress(100.0, message)
return result_payload
async def _run_index_file_ids(
*,
context: TaskContext,
kb_id: str,
file_ids: list[str],
operator_id: str,
params: dict,
) -> dict:
await context.set_message("任务初始化")
await context.set_progress(5.0, "准备入库文档")
total = len(file_ids)
processed_items = []
param_update_failed = set()
if params:
for file_id in file_ids:
try:
await knowledge_base.update_file_params(kb_id, file_id, params, operator_id=operator_id)
except Exception as e:
logger.error(f"Failed to update params for {file_id}: {e}")
param_update_failed.add(file_id)
processed_items.append({"file_id": file_id, "status": "failed", "error": f"参数更新失败: {str(e)}"})
for idx, file_id in enumerate(file_ids, 1):
await context.raise_if_cancelled()
if file_id in param_update_failed:
logger.debug(f"Skipping {file_id} due to param update failure")
continue
progress = 5.0 + (idx / total) * 90.0
await context.set_progress(progress, f"正在入库第 {idx}/{total} 个文档")
try:
result = await knowledge_base.index_file(kb_id, file_id, operator_id=operator_id, params=params)
processed_items.append(result)
except Exception as e:
logger.error(f"Index failed for {file_id}: {e}")
processed_items.append({"file_id": file_id, "status": "failed", "error": str(e)})
failed_count = len([p for p in processed_items if _is_failed_item(p)])
message = f"入库完成,失败 {failed_count} 个"
result_payload = {"items": processed_items, "processed": len(processed_items), "failed": failed_count}
await context.set_result(result_payload)
await context.set_progress(100.0, message)
return result_payload
async def _run_parse_pending_statuses(
*,
context: TaskContext,
kb_id: str,
statuses: list[str],
initial_total: int,
operator_id: str,
) -> dict:
await context.set_message("任务初始化")
await context.set_progress(5.0, "准备解析待处理文档")
processed_count = 0
failed_count = 0
result_items = []
after_file_id = None
while True:
file_ids = await knowledge_base.list_document_file_ids_by_statuses(
kb_id,
statuses=statuses,
after_file_id=after_file_id,
limit=DOCUMENT_ACTION_BATCH_SIZE,
)
if not file_ids:
break
for file_id in file_ids:
await context.raise_if_cancelled()
after_file_id = file_id
processed_count += 1
progress_total = max(initial_total, processed_count)
progress = 5.0 + (processed_count / progress_total) * 90.0
await context.set_progress(progress, f"正在解析第 {processed_count}/{progress_total} 个文档")
try:
result = await knowledge_base.parse_file(kb_id, file_id, operator_id=operator_id)
_append_document_action_result_sample(result_items, result)
except Exception as e:
failed_count += 1
logger.error(f"Parse failed for {file_id}: {e}")
_append_document_action_result_sample(
result_items,
{"file_id": file_id, "status": "failed", "error": str(e)},
)
message = f"解析完成,失败 {failed_count} 个" if processed_count else "没有待解析文档"
result_payload = {
"items": result_items,
"processed": processed_count,
"failed": failed_count,
"result_truncated": processed_count > len(result_items),
}
await context.set_result(result_payload)
await context.set_progress(100.0, message)
return result_payload
async def _run_index_pending_statuses(
*,
context: TaskContext,
kb_id: str,
statuses: list[str],
initial_total: int,
operator_id: str,
params: dict,
) -> dict:
await context.set_message("任务初始化")
await context.set_progress(5.0, "准备入库待处理文档")
processed_count = 0
failed_count = 0
result_items = []
after_file_id = None
while True:
file_ids = await knowledge_base.list_document_file_ids_by_statuses(
kb_id,
statuses=statuses,
after_file_id=after_file_id,
limit=DOCUMENT_ACTION_BATCH_SIZE,
)
if not file_ids:
break
for file_id in file_ids:
await context.raise_if_cancelled()
after_file_id = file_id
processed_count += 1
progress_total = max(initial_total, processed_count)
progress = 5.0 + (processed_count / progress_total) * 90.0
await context.set_progress(progress, f"正在入库第 {processed_count}/{progress_total} 个文档")
try:
if params:
await knowledge_base.update_file_params(kb_id, file_id, params, operator_id=operator_id)
result = await knowledge_base.index_file(kb_id, file_id, operator_id=operator_id, params=params)
_append_document_action_result_sample(result_items, result)
except Exception as e:
failed_count += 1
logger.error(f"Index failed for {file_id}: {e}")
_append_document_action_result_sample(
result_items,
{"file_id": file_id, "status": "failed", "error": str(e)},
)
message = f"入库完成,失败 {failed_count} 个" if processed_count else "没有待入库文档"
result_payload = {
"items": result_items,
"processed": processed_count,
"failed": failed_count,
"result_truncated": processed_count > len(result_items),
}
await context.set_result(result_payload)
await context.set_progress(100.0, message)
return result_payload
@knowledge.post("/databases/{kb_id}/documents/parse")
async def parse_documents(kb_id: str, file_ids: list[str] = Body(...), current_user: User = Depends(get_admin_user)):
"""手动触发文档解析"""
file_ids = _validate_direct_document_action_file_ids(file_ids)
logger.debug(f"Parse documents for kb_id {kb_id}: {file_ids}")
await _ensure_database_supports_documents(kb_id, "文档解析")
async def run_parse(context: TaskContext):
try:
return await _run_parse_file_ids(
context=context,
kb_id=kb_id,
file_ids=file_ids,
operator_id=current_user.uid,
)
except Exception as e:
logger.exception(f"Parse task failed: {e}")
raise
try:
database = await knowledge_base.get_database_info(kb_id)
task = await tasker.enqueue(
name=f"文档解析 ({database['name']})",
task_type="knowledge_parse",
payload={"kb_id": kb_id, "file_ids": file_ids},
coroutine=run_parse,
)
return {"message": "解析任务已提交", "status": "queued", "task_id": task.id}
except Exception as e:
return {"message": f"提交失败: {e}", "status": "failed"}
@knowledge.post("/databases/{kb_id}/documents/parse-pending")
async def parse_pending_documents(kb_id: str, current_user: User = Depends(get_admin_user)):
"""按状态手动触发全部待解析文档解析。"""
logger.debug(f"Parse pending documents for kb_id {kb_id}")
await _ensure_database_supports_documents(kb_id, "文档解析")
try:
database = await knowledge_base.get_database_info(kb_id)
pending_count = int((database.get("stats") or {}).get("pending_parse_count") or 0)
if pending_count <= 0:
return {"message": "没有待解析文档", "status": "success", "queued_count": 0}
async def run_parse(context: TaskContext):
try:
return await _run_parse_pending_statuses(
context=context,
kb_id=kb_id,
statuses=PENDING_PARSE_STATUSES,
initial_total=pending_count,
operator_id=current_user.uid,
)
except Exception as e:
logger.exception(f"Pending parse task failed: {e}")
raise
task, created = await tasker.enqueue_unique_by_payload(
name=f"待解析文档解析 ({database['name']})",
task_type="knowledge_parse",
payload={
"kb_id": kb_id,
"scope": "pending",
"action": "parse",
"statuses": PENDING_PARSE_STATUSES,
"count": pending_count,
},
payload_match={"kb_id": kb_id, "scope": "pending", "action": "parse"},
statuses=ACTIVE_DOCUMENT_ACTION_TASK_STATUSES,
coroutine=run_parse,
)
return {
"message": "解析任务已提交" if created else "已有待解析任务正在执行",
"status": "queued",
"task_id": task.id,
"queued_count": pending_count,
}
except Exception as e:
return {"message": f"提交失败: {e}", "status": "failed"}
@knowledge.post("/databases/{kb_id}/documents/index")
async def index_documents(
kb_id: str,
file_ids: list[str] = Body(...),
params: dict | None = Body(None),
current_user: User = Depends(get_admin_user),
):
"""手动触发文档入库(Indexing),支持更新参数"""
file_ids = _validate_direct_document_action_file_ids(file_ids)
params = params or {}
logger.debug(f"Index documents for kb_id {kb_id}: {file_ids} {params=}")
await _ensure_database_supports_documents(kb_id, "文档入库")
operator_id = current_user.uid
async def run_index(context: TaskContext):
try:
return await _run_index_file_ids(
context=context,
kb_id=kb_id,
file_ids=file_ids,
operator_id=operator_id,
params=params,
)
except Exception as e:
logger.exception(f"Index task failed: {e}")
raise
try:
database = await knowledge_base.get_database_info(kb_id)
task = await tasker.enqueue(
name=f"文档入库 ({database['name']})",
task_type="knowledge_index",
payload={"kb_id": kb_id, "file_ids": file_ids, "params": params},
coroutine=run_index,
)
return {"message": "入库任务已提交", "status": "queued", "task_id": task.id}
except Exception as e:
return {"message": f"提交失败: {e}", "status": "failed"}
@knowledge.post("/databases/{kb_id}/documents/index-pending")
async def index_pending_documents(
kb_id: str,
payload: PendingIndexDocumentsRequest | None = None,
current_user: User = Depends(get_admin_user),
):
"""按状态手动触发全部待入库文档入库。"""
params = payload.params if payload else None
params = params or {}
logger.debug(f"Index pending documents for kb_id {kb_id}: {params=}")
await _ensure_database_supports_documents(kb_id, "文档入库")
try:
database = await knowledge_base.get_database_info(kb_id)
pending_count = int((database.get("stats") or {}).get("pending_index_count") or 0)
if pending_count <= 0:
return {"message": "没有待入库文档", "status": "success", "queued_count": 0}
operator_id = current_user.uid
async def run_index(context: TaskContext):
try:
return await _run_index_pending_statuses(
context=context,
kb_id=kb_id,
statuses=PENDING_INDEX_STATUSES,
initial_total=pending_count,
operator_id=operator_id,
params=params,
)
except Exception as e:
logger.exception(f"Pending index task failed: {e}")
raise
task, created = await tasker.enqueue_unique_by_payload(
name=f"待入库文档入库 ({database['name']})",
task_type="knowledge_index",
payload={
"kb_id": kb_id,
"scope": "pending",
"action": "index",
"statuses": PENDING_INDEX_STATUSES,
"count": pending_count,
"params": params,
},
payload_match={"kb_id": kb_id, "scope": "pending", "action": "index"},
statuses=ACTIVE_DOCUMENT_ACTION_TASK_STATUSES,
coroutine=run_index,
)
return {
"message": "入库任务已提交" if created else "已有待入库任务正在执行",
"status": "queued",
"task_id": task.id,
"queued_count": pending_count,
}
except Exception as e:
return {"message": f"提交失败: {e}", "status": "failed"}
@knowledge.get("/databases/{kb_id}/documents/{doc_id}")
async def get_document_info(kb_id: str, doc_id: str, current_user: User = Depends(get_admin_user)):
"""获取文档详细信息(包含基本信息和内容信息)"""
logger.debug(f"GET document {doc_id} info in {kb_id}")
await _ensure_database_supports_documents(kb_id, "文档查看")
try:
info = await knowledge_base.get_file_info(kb_id, doc_id)
return info
except Exception as e:
logger.error(f"Failed to get file info, {e}, {kb_id=}, {doc_id=}, {traceback.format_exc()}")
return {"message": "Failed to get file info", "status": "failed"}
@knowledge.get("/databases/{kb_id}/documents/{doc_id}/basic")
async def get_document_basic_info(kb_id: str, doc_id: str, current_user: User = Depends(get_admin_user)):
"""获取文档基本信息(仅元数据)"""
logger.debug(f"GET document {doc_id} basic info in {kb_id}")
await _ensure_database_supports_documents(kb_id, "文档查看")
try:
info = await knowledge_base.get_file_basic_info(kb_id, doc_id)
return info
except Exception as e:
logger.error(f"Failed to get file basic info, {e}, {kb_id=}, {doc_id=}, {traceback.format_exc()}")
return {"message": "Failed to get file basic info", "status": "failed"}
@knowledge.get("/databases/{kb_id}/documents/{doc_id}/content")
async def get_document_content(kb_id: str, doc_id: str, current_user: User = Depends(get_admin_user)):
"""获取文档内容信息(chunks和lines"""
logger.debug(f"GET document {doc_id} content in {kb_id}")
await _ensure_database_supports_documents(kb_id, "文档查看")
try:
info = await knowledge_base.get_file_content(kb_id, doc_id)
return info
except Exception as e:
logger.error(f"Failed to get file content, {e}, {kb_id=}, {doc_id=}, {traceback.format_exc()}")
return {"message": "Failed to get file content", "status": "failed"}
@knowledge.delete("/databases/{kb_id}/documents/batch")
async def batch_delete_documents(
kb_id: str, file_ids: list[str] = Body(...), current_user: User = Depends(get_admin_user)
):
"""批量删除文档或文件夹"""
logger.debug(f"BATCH DELETE documents {file_ids} in {kb_id}")
await _ensure_database_supports_documents(kb_id, "批量文档删除")
deleted_count = 0
failed_items = []
mindmap_removals: list[tuple[str, str]] = []
for doc_id in file_ids:
try:
file_meta_info = await knowledge_base.get_file_basic_info(kb_id, doc_id)
# Check if it is a folder
is_folder = file_meta_info.get("meta", {}).get("is_folder", False)
if is_folder:
await knowledge_base.delete_folder(kb_id, doc_id)
deleted_count += 1
continue
file_path = file_meta_info.get("meta", {}).get("path", "")
await _delete_document_storage_objects(kb_id, doc_id, file_path)
# 无论MinIO删除是否成功,都继续从知识库删除
await knowledge_base.delete_file(kb_id, doc_id)
deleted_count += 1
# 只有成功删除的文件才同步从导图快照移除,避免部分失败导致导图与文件表失同步
removed_filename = file_meta_info.get("meta", {}).get("filename", "")
if removed_filename:
mindmap_removals.append((doc_id, removed_filename))
except Exception as e:
logger.error(f"批量删除过程中删除文档 {doc_id} 失败: {e}, {traceback.format_exc()}")
failed_items.append({"doc_id": doc_id, "error": str(e)})
# 同步清理导图快照,移除已删除文件对应的叶子节点
await batch_remove_files_from_mindmap(kb_id, mindmap_removals)
if failed_items:
if deleted_count == 0:
raise HTTPException(status_code=400, detail=f"批量删除失败: 所有 {len(failed_items)} 个文件均未删除。")
return {
"message": f"部分删除成功: 已删除 {deleted_count} 个文件,失败 {len(failed_items)} 个",
"deleted_count": deleted_count,
"failed_items": failed_items,
}
return {"message": f"批量删除成功: 已删除 {deleted_count} 个文件", "deleted_count": deleted_count}
@knowledge.delete("/databases/{kb_id}/documents/{doc_id}")
async def delete_document(kb_id: str, doc_id: str, current_user: User = Depends(get_admin_user)):
"""删除文档或文件夹"""
logger.debug(f"DELETE document {doc_id} info in {kb_id}")
await _ensure_database_supports_documents(kb_id, "文档删除")
try:
file_meta_info = await knowledge_base.get_file_basic_info(kb_id, doc_id)
# Check if it is a folder
is_folder = file_meta_info.get("meta", {}).get("is_folder", False)
if is_folder:
await knowledge_base.delete_folder(kb_id, doc_id)
return {"message": "文件夹删除成功"}
file_path = file_meta_info.get("meta", {}).get("path", "")
await _delete_document_storage_objects(kb_id, doc_id, file_path)
# 无论MinIO删除是否成功,都继续从知识库删除
await knowledge_base.delete_file(kb_id, doc_id)
# 同步清理导图快照,移除已删除文件对应的叶子节点
removed_filename = file_meta_info.get("meta", {}).get("filename", "")
await remove_file_from_mindmap(kb_id, doc_id, removed_filename)
return {"message": "删除成功"}
except Exception as e:
logger.error(f"删除文档失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=400, detail=f"删除文档失败: {e}")
@knowledge.get("/databases/{kb_id}/documents/{doc_id}/download")
async def download_document(kb_id: str, doc_id: str, current_user: User = Depends(get_admin_user)):
"""下载原始文件"""
logger.debug(f"Download document {doc_id} from {kb_id}")
await _ensure_database_supports_documents(kb_id, "文档下载")
try:
file_info = await knowledge_base.get_file_basic_info(kb_id, doc_id)
file_meta = file_info.get("meta", {})
# 获取文件类型、路径和文件名
file_type = file_meta.get("file_type", "file")
file_path = file_meta.get("path", "")
filename = file_meta.get("filename", "file")
# URL 类型文件没有原始文件可下载
if file_type == "url":
raise HTTPException(status_code=400, detail="URL 类型文件不支持下载原始文件")
logger.debug(f"File path from database: {file_path}")
logger.debug(f"Original filename from database: {filename}")
# 解码URL编码的文件名(如果有的话)
try:
decoded_filename = unquote(filename, encoding="utf-8")
logger.debug(f"Decoded filename: {decoded_filename}")
except Exception as e:
logger.debug(f"Failed to decode filename {filename}: {e}")
decoded_filename = filename # 如果解码失败,使用原文件名
_, ext = os.path.splitext(decoded_filename)
media_type = media_types.get(ext.lower(), "application/octet-stream")
if not is_minio_url(file_path):
raise HTTPException(status_code=400, detail="文件路径必须是 MinIO URL")
logger.debug(f"Downloading from MinIO: {file_path}")
try:
bucket_name, object_name = parse_minio_url(file_path)
logger.debug(f"Parsed bucket_name: {bucket_name}, object_name: {object_name}")
minio_client = get_minio_client()
# 直接使用解析出的完整对象名称下载
minio_response = await minio_client.adownload_response(
bucket_name=bucket_name,
object_name=object_name,
)
logger.debug(f"Successfully downloaded object: {object_name}")
except Exception as e:
logger.error(f"Failed to download MinIO file: {e}")
raise StorageError(f"下载文件失败: {e}")
# 创建流式生成器
async def minio_stream():
try:
while True:
chunk = await asyncio.to_thread(minio_response.read, 8192)
if not chunk:
break
yield chunk
finally:
minio_response.close()
minio_response.release_conn()
response = StreamingResponse(
minio_stream(),
media_type=media_type,
)
try:
decoded_filename.encode("ascii")
response.headers["Content-Disposition"] = f'attachment; filename="{decoded_filename}"'
except UnicodeEncodeError:
encoded_filename = quote(decoded_filename.encode("utf-8"))
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
return response
except HTTPException:
raise
except Exception as e:
logger.error(f"下载文件失败: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"下载失败: {e}")
# =============================================================================
# === 知识库查询分组 ===
# =============================================================================
@knowledge.post("/databases/{kb_id}/query")
async def query_knowledge_base(
kb_id: str, query: str = Body(...), meta: dict = Body(...), current_user: User = Depends(get_admin_user)
):
"""查询知识库"""
logger.debug(f"Query knowledge base {kb_id}: {query}")
try:
result = await knowledge_base.aquery(query, kb_id=kb_id, **meta)
return {"result": result, "status": "success"}
except Exception as e:
logger.error(f"知识库查询失败 {e}, {traceback.format_exc()}")
return {"message": f"知识库查询失败: {e}", "status": "failed"}
@knowledge.post("/databases/{kb_id}/query-test")
async def query_test(
kb_id: str, query: str = Body(...), meta: dict = Body(...), current_user: User = Depends(get_admin_user)
):
"""测试查询知识库"""
logger.debug(f"Query test in {kb_id}: {query}")
try:
result = await knowledge_base.aquery(query, kb_id=kb_id, **meta)
return result
except Exception as e:
logger.error(f"测试查询失败 {e}, {traceback.format_exc()}")
return {"message": f"测试查询失败: {e}", "status": "failed"}
@knowledge.put("/databases/{kb_id}/query-params")
async def update_knowledge_base_query_params(
kb_id: str, params: dict = Body(...), current_user: User = Depends(get_admin_user)
):
"""更新知识库查询参数配置"""
try:
# 获取知识库实例
kb_instance = await knowledge_base._get_kb_for_database(kb_id)
if not kb_instance:
raise HTTPException(status_code=404, detail="Knowledge base not found")
# 更新实例元数据中的查询参数
async with knowledge_base._metadata_lock:
# 确保 kb_id 在实例的 databases_meta 中
if kb_id not in kb_instance.databases_meta:
raise HTTPException(status_code=404, detail="Database not found in instance metadata")
# 确保 query_params 不为 None
if kb_instance.databases_meta[kb_id].get("query_params") is None:
kb_instance.databases_meta[kb_id]["query_params"] = {}
options = kb_instance.databases_meta[kb_id]["query_params"].setdefault("options", {})
options.update(params)
updated_query_params = kb_instance.databases_meta[kb_id]["query_params"]
# 直接通过 Repository 更新单条记录,避免调用 _save_metadata() 遍历所有数据库和文件
from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository
kb_repo = KnowledgeBaseRepository()
await kb_repo.update(kb_id, {"query_params": updated_query_params})
logger.info(f"更新知识库 {kb_id} 查询参数: {params}")
return {"message": "success", "data": params}
except HTTPException:
raise
except Exception as e:
logger.error(f"更新知识库查询参数失败: {e}")
raise HTTPException(status_code=500, detail=f"更新查询参数失败: {str(e)}")
@knowledge.get("/databases/{kb_id}/query-params")
async def get_knowledge_base_query_params(kb_id: str, current_user: User = Depends(get_admin_user)):
"""获取知识库类型特定的查询参数"""
try:
# 获取知识库实例
kb_instance = await knowledge_base._get_kb_for_database(kb_id)
# 调用知识库实例的方法获取配置
params = kb_instance.get_query_params_config(kb_id=kb_id)
# 获取用户保存的配置并合并(从实例 metadata 读取)
saved_options = kb_instance._get_query_params(kb_id)
if saved_options:
params = _merge_saved_options(params, saved_options)
return {"params": params, "message": "success"}
except Exception as e:
logger.error(f"获取知识库查询参数失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
def _merge_saved_options(params: dict, saved_options: dict) -> dict:
"""将用户保存的配置合并到默认配置中"""
for option in params.get("options", []):
key = option.get("key")
if key in saved_options:
option["default"] = saved_options[key]
return params
# =============================================================================
# === AI生成示例问题 ===
# =============================================================================
@knowledge.post("/databases/{kb_id}/sample-questions")
async def generate_sample_questions(
kb_id: str,
request_body: dict = Body(...),
current_user: User = Depends(get_admin_user),
):
"""AI生成针对知识库的测试问题。"""
try:
count = request_body.get("count", 10)
return await generate_database_sample_questions(kb_id, count=count)
except HTTPException:
raise
except Exception as e:
logger.error(f"生成知识库问题失败: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"生成问题失败: {str(e)}")
@knowledge.get("/databases/{kb_id}/sample-questions")
async def get_sample_questions(kb_id: str, current_user: User = Depends(get_admin_user)):
"""获取知识库的测试问题。"""
try:
return await get_database_sample_questions(kb_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取知识库问题失败: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"获取问题失败: {str(e)}")
# =============================================================================
# === 文件管理分组 ===
# =============================================================================
@knowledge.post("/databases/{kb_id}/folders")
async def create_folder(
kb_id: str,
folder_name: str = Body(..., embed=True),
parent_id: str | None = Body(None, embed=True),
current_user: User = Depends(get_admin_user),
):
"""创建文件夹"""
try:
await _ensure_database_supports_documents(kb_id, "文件夹创建")
return await knowledge_base.create_folder(kb_id, folder_name, parent_id)
except HTTPException:
raise
except Exception as e:
logger.error(f"创建文件夹失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@knowledge.put("/databases/{kb_id}/documents/{doc_id}/move")
async def move_document(
kb_id: str,
doc_id: str,
new_parent_id: str | None = Body(..., embed=True),
current_user: User = Depends(get_admin_user),
):
"""移动文件或文件夹"""
logger.debug(f"Move document {doc_id} to {new_parent_id} in {kb_id}")
try:
await _ensure_database_supports_documents(kb_id, "文件移动")
return await knowledge_base.move_file(kb_id, doc_id, new_parent_id)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"移动文件失败 {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=str(e))
@knowledge.post("/files/fetch-url")
async def fetch_url(
url: str = Body(..., embed=True),
kb_id: str | None = Body(None, embed=True),
current_user: User = Depends(get_admin_user),
):
"""
抓取 URL 内容并上传到 MinIO
"""
logger.debug(f"Fetching URL: {url} for kb_id: {kb_id}")
try:
# 1. 下载内容 (包含白名单校验、大小限制、类型检查)
content_bytes, final_url = await fetch_url_content(url)
# 2. 计算 Hash
content_hash = await calculate_content_hash(content_bytes)
# 检查是否已存在相同内容的文件
if kb_id:
file_exists = await knowledge_base.file_existed_in_db(kb_id, content_hash)
if file_exists:
raise HTTPException(
status_code=409,
detail="数据库中已经存在了相同内容文件",
)
# 3. 上传到 MinIO
minio_client = get_minio_client()
bucket_name = MinIOClient.KB_BUCKETS["documents"]
await asyncio.to_thread(minio_client.ensure_bucket_exists, bucket_name)
folder = kb_id if kb_id else "unknown"
object_name = f"{folder}/upload/{content_hash}.html"
upload_result = await minio_client.aupload_file(
bucket_name=bucket_name,
object_name=object_name,
data=content_bytes,
content_type="text/html",
)
# 检测同名文件(URL即为文件名)
same_name_files = []
has_same_name = False
if kb_id:
same_name_files = await knowledge_base.get_same_name_files(kb_id, url)
has_same_name = len(same_name_files) > 0
return {
"status": "success",
"file_path": upload_result.url,
"minio_url": upload_result.url,
"content_hash": content_hash,
"filename": url, # 原始 URL 作为文件名
"final_url": final_url,
"size": len(content_bytes),
"has_same_name": has_same_name,
"same_name_files": same_name_files,
}
except HTTPException:
raise
except ValueError as e:
logger.warning(f"URL fetch validation failed: {e}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to fetch URL {url}: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"Failed to fetch URL: {str(e)}")
@knowledge.post("/files/import-workspace")
async def import_workspace_files(
payload: WorkspaceImportRequest,
current_user: User = Depends(get_admin_user),
):
"""将当前用户工作区文件导入 MinIO,返回与普通文件上传一致的预处理结果。"""
kb_id = payload.kb_id.strip()
paths = [path for path in payload.paths if str(path or "").strip()]
if not kb_id:
raise HTTPException(status_code=400, detail="kb_id is required")
if not paths:
raise HTTPException(status_code=400, detail="请选择至少一个工作区文件")
await _ensure_database_supports_documents(kb_id, "文档添加/解析/入库")
bucket_name = MinIOClient.KB_BUCKETS["documents"]
results = []
for workspace_path in paths:
target = resolve_workspace_file_path(path=workspace_path, current_user=current_user)
filename = target.name
ext = os.path.splitext(filename)[1].lower()
if not is_supported_file_extension(filename):
raise HTTPException(status_code=400, detail=f"Unsupported file type: {ext}")
size = target.stat().st_size
if size > MAX_WORKSPACE_UPLOAD_SIZE_BYTES:
raise HTTPException(status_code=400, detail="文件过大,当前仅支持 100 MB 以内的工作区文件")
file_bytes = await asyncio.to_thread(target.read_bytes)
content_hash = await calculate_content_hash(file_bytes)
file_exists = await knowledge_base.file_existed_in_db(kb_id, content_hash)
if file_exists:
raise HTTPException(status_code=409, detail=f"数据库中已经存在了相同内容文件: {filename}")
basename, ext = os.path.splitext(filename)
timestamp = int(time.time() * 1000)
minio_filename = f"{basename}_{timestamp}{ext}"
object_name = f"{kb_id}/upload/{minio_filename}"
minio_url = await aupload_file_to_minio(bucket_name, object_name, file_bytes)
normalized_filename = filename.lower()
same_name_files = await knowledge_base.get_same_name_files(kb_id, normalized_filename)
results.append(
{
"message": "Workspace file successfully imported",
"file_path": minio_url,
"minio_path": minio_url,
"kb_id": kb_id,
"content_hash": content_hash,
"filename": normalized_filename,
"original_filename": basename,
"size": len(file_bytes),
"minio_filename": minio_filename,
"object_name": object_name,
"bucket_name": bucket_name,
"workspace_path": workspace_path,
"same_name_files": same_name_files,
"has_same_name": len(same_name_files) > 0,
}
)
return {"status": "success", "items": results}
@knowledge.post("/files/upload")
async def upload_file(
file: UploadFile = File(...),
kb_id: str | None = Query(None),
current_user: User = Depends(get_admin_user),
):
"""上传文件"""
if not file.filename:
raise HTTPException(status_code=400, detail="No selected file")
if kb_id:
await _ensure_database_supports_documents(kb_id, "文档上传")
logger.debug(f"Received upload file with filename: {file.filename}")
ext = os.path.splitext(file.filename)[1].lower()
if not is_supported_file_extension(file.filename):
raise HTTPException(status_code=400, detail=f"Unsupported file type: {ext}")
basename, ext = os.path.splitext(file.filename)
# 直接使用原始文件名(小写)
filename = f"{basename}{ext}".lower()
try:
file_bytes = await read_upload_with_limit(
file,
max_size_bytes=MAX_UPLOAD_SIZE_BYTES,
too_large_message="文件过大,当前仅支持 100 MB 以内的文件",
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
content_hash = await calculate_content_hash(file_bytes)
file_exists = await knowledge_base.file_existed_in_db(kb_id, content_hash)
if file_exists:
raise HTTPException(
status_code=409,
detail="数据库中已经存在了相同内容文件,File with the same content already exists in this database",
)
# 直接上传到MinIO,添加时间戳区分版本
timestamp = int(time.time() * 1000)
minio_filename = f"{basename}_{timestamp}{ext}"
bucket_name = MinIOClient.KB_BUCKETS["documents"]
folder = kb_id if kb_id else "unknown"
object_name = f"{folder}/upload/{minio_filename}"
# 上传到MinIO
minio_url = await aupload_file_to_minio(bucket_name, object_name, file_bytes)
# 检测同名文件(基于原始文件名)
same_name_files = await knowledge_base.get_same_name_files(kb_id, filename)
has_same_name = len(same_name_files) > 0
return {
"message": "File successfully uploaded",
"file_path": minio_url, # MinIO路径作为主要路径
"minio_path": minio_url, # MinIO路径
"kb_id": kb_id,
"content_hash": content_hash,
"filename": filename, # 原始文件名(小写)
"original_filename": basename, # 原始文件名(去掉后缀)
"size": len(file_bytes),
"minio_filename": minio_filename, # MinIO中的文件名(带时间戳)
"object_name": object_name,
"bucket_name": bucket_name, # MinIO存储桶名称
"same_name_files": same_name_files, # 同名文件列表
"has_same_name": has_same_name, # 是否包含同名文件标志
}
@knowledge.get("/files/supported-types")
async def get_supported_file_types(current_user: User = Depends(get_admin_user)):
"""获取当前支持的文件类型"""
return {"message": "success", "file_types": sorted(SUPPORTED_FILE_EXTENSIONS)}
@knowledge.post("/files/markdown")
async def mark_it_down(file: UploadFile = File(...), current_user: User = Depends(get_admin_user)):
"""调用统一 Parser 将文件解析为 markdown,需要管理员权限"""
import tempfile
if not file.filename:
return {"message": "文件解析失败: 无法识别文件名", "markdown_content": ""}
suffix = os.path.splitext(file.filename)[1].lower()
temp_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
temp_path = temp_file.name
await write_upload_to_path(
file,
temp_path,
max_size_bytes=MAX_UPLOAD_SIZE_BYTES,
too_large_message="文件过大,当前仅支持 100 MB 以内的文件",
)
markdown_content = await Parser.aparse(temp_path)
return {"markdown_content": markdown_content, "message": "success"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"文件解析失败 {e}, {traceback.format_exc()}")
return {"message": f"文件解析失败 {e}", "markdown_content": ""}
finally:
if temp_path and os.path.exists(temp_path):
try:
os.unlink(temp_path)
except Exception as cleanup_error:
logger.warning(f"临时文件清理失败 {temp_path}: {cleanup_error}")
# =============================================================================
# === 知识库类型分组 ===
# =============================================================================
@knowledge.get("/types")
async def get_knowledge_base_types(current_user: User = Depends(get_admin_user)):
"""获取支持的知识库类型"""
try:
kb_types = knowledge_base.get_supported_kb_types()
return {"kb_types": kb_types, "message": "success"}
except Exception as e:
logger.error(f"获取知识库类型失败 {e}, {traceback.format_exc()}")
return {"message": f"获取知识库类型失败 {e}", "kb_types": {}}
@knowledge.get("/chunk-presets")
async def get_knowledge_chunk_presets(current_user: User = Depends(get_admin_user)):
"""获取支持的知识库分块策略"""
return {"chunk_presets": get_chunk_preset_options(), "message": "success"}
@knowledge.get("/stats")
async def get_knowledge_base_statistics(current_user: User = Depends(get_admin_user)):
"""获取知识库统计信息"""
try:
stats = await knowledge_base.get_statistics()
return {"stats": stats, "message": "success"}
except Exception as e:
logger.error(f"获取知识库统计失败 {e}, {traceback.format_exc()}")
return {"message": f"获取知识库统计失败 {e}", "stats": {}}
# =============================================================================
# === 知识库 AI 辅助功能分组 ===
# =============================================================================
@knowledge.post("/generate-description")
async def generate_description(
name: str = Body(..., description="知识库名称"),
current_description: str = Body("", description="当前描述(可选,用于优化)"),
file_list: list[str] | None = Body(None, description="文件列表"),
current_user: User = Depends(get_admin_user),
):
"""使用 LLM 生成或优化知识库描述
根据知识库名称和现有描述,使用 LLM 生成适合作为智能体工具描述的内容。
"""
from yuxi.models import select_model
file_list = file_list or []
logger.debug(f"Generating description for knowledge base: {name}, files: {len(file_list)}")
# 构建文件列表文本
if file_list:
# 限制文件数量,避免 prompt 过长
display_files = file_list[:50]
files_str = "\n".join([f"- {f}" for f in display_files])
more_text = f"\n... (还有 {len(file_list) - 50} 个文件)" if len(file_list) > 50 else ""
current_description += f"\n\n知识库包含的文件:\n{files_str}{more_text}"
current_description = current_description or "暂无描述"
# 构建提示词
prompt = textwrap.dedent(f"""
请帮我优化以下知识库的描述。
知识库名称: {name}
当前描述: {current_description}
要求:
1. 这个描述将作为智能体工具的描述使用
2. 智能体会根据知识库的标题和描述来选择合适的工具
3. 所以描述需要清晰、具体,说明该知识库包含什么内容、适合解答什么类型的问题
4. 描述应该简洁有力,通常 2-4 句话即可
5. 不要使用 Markdown 格式
{"6. 请参考提供的文件列表来准确概括知识库内容" if file_list else ""}
请直接输出优化后的描述,不要有任何前缀说明。
""").strip()
try:
model = select_model(model_spec=config.default_model)
response = await model.call(prompt)
description = response.content.strip()
logger.debug(f"Generated description: {description}")
return {"description": description, "status": "success"}
except Exception as e:
logger.error(f"生成描述失败: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"生成描述失败: {e}")