1665 lines
62 KiB
Python
1665 lines
62 KiB
Python
import asyncio
|
||
import mimetypes
|
||
import os
|
||
import re
|
||
import secrets
|
||
import string
|
||
from abc import ABC, abstractmethod
|
||
from typing import Any
|
||
|
||
from yuxi.knowledge.chunking.ragflow_like.presets import ensure_chunk_defaults_in_additional_params
|
||
from yuxi.knowledge.schemas import FindOutputSchema, FindWindowSchema, SearchOutputSchema, SearchResultSchema
|
||
from yuxi.knowledge.utils import resolve_processing_params, sanitize_processing_params
|
||
from yuxi.utils import logger
|
||
from yuxi.utils.datetime_utils import coerce_any_to_utc_datetime, utc_isoformat
|
||
|
||
|
||
class FileStatus:
|
||
UPLOADED = "uploaded"
|
||
PARSING = "parsing"
|
||
PARSED = "parsed"
|
||
ERROR_PARSING = "error_parsing"
|
||
INDEXING = "indexing"
|
||
INDEXED = "indexed"
|
||
ERROR_INDEXING = "error_indexing"
|
||
|
||
|
||
class KnowledgeBaseException(Exception):
|
||
"""知识库统一异常基类"""
|
||
|
||
pass
|
||
|
||
|
||
class KBNotFoundError(KnowledgeBaseException):
|
||
"""知识库不存在错误"""
|
||
|
||
pass
|
||
|
||
|
||
class KBOperationError(KnowledgeBaseException):
|
||
"""知识库操作错误"""
|
||
|
||
pass
|
||
|
||
|
||
class KnowledgeBase(ABC):
|
||
"""知识库抽象基类,定义统一接口"""
|
||
|
||
kb_type = ""
|
||
name = ""
|
||
description = ""
|
||
requires_embedding_model = True
|
||
supports_documents = True
|
||
apply_chunk_defaults = True
|
||
|
||
# 类级别的处理队列,跟踪所有正在处理的文件
|
||
_processing_files = set()
|
||
_processing_lock = None
|
||
|
||
def __init__(self, work_dir: str):
|
||
"""
|
||
初始化知识库
|
||
|
||
Args:
|
||
work_dir: 工作目录
|
||
"""
|
||
import threading
|
||
|
||
self.work_dir = work_dir
|
||
self.databases_meta: dict[str, dict] = {}
|
||
self.files_meta: dict[str, dict] = {}
|
||
self.benchmarks_meta: dict[str, dict] = {}
|
||
self._metadata_loaded = False # 标记元数据是否已加载
|
||
|
||
# 初始化类级别的锁
|
||
if KnowledgeBase._processing_lock is None:
|
||
KnowledgeBase._processing_lock = threading.Lock()
|
||
|
||
os.makedirs(work_dir, exist_ok=True)
|
||
|
||
# 注意:不在 __init__ 中加载元数据,由 KnowledgeBaseManager 统一管理加载
|
||
|
||
def load_metadata(
|
||
self, global_databases_meta: dict[str, dict], files_meta: dict[str, dict], benchmarks_meta: dict[str, dict]
|
||
):
|
||
"""由 KnowledgeBaseManager 调用,同步加载元数据"""
|
||
# 过滤出当前 kb_type 的知识库
|
||
self.databases_meta = {}
|
||
for kb_id, meta in global_databases_meta.items():
|
||
if meta.get("kb_type") == self.kb_type:
|
||
normalized_additional_params = self.normalize_additional_params(meta.get("additional_params"))
|
||
self.databases_meta[kb_id] = {
|
||
"name": meta.get("name"),
|
||
"description": meta.get("description"),
|
||
"kb_type": meta.get("kb_type"),
|
||
"embedding_model_spec": meta.get("embedding_model_spec"),
|
||
"llm_model_spec": meta.get("llm_model_spec"),
|
||
"query_params": meta.get("query_params"),
|
||
"metadata": normalized_additional_params,
|
||
"created_at": meta.get("created_at"),
|
||
}
|
||
|
||
# 过滤文件
|
||
self.files_meta = {}
|
||
for file_id, meta in files_meta.items():
|
||
if meta.get("kb_id") in self.databases_meta:
|
||
kb_id = meta.get("kb_id")
|
||
kb_additional_params = self.databases_meta.get(kb_id, {}).get("metadata") or {}
|
||
normalized_meta = dict(meta)
|
||
normalized_meta["processing_params"] = resolve_processing_params(
|
||
kb_additional_params=kb_additional_params,
|
||
file_processing_params=meta.get("processing_params"),
|
||
)
|
||
self.files_meta[file_id] = normalized_meta
|
||
|
||
# 过滤评估基准
|
||
self.benchmarks_meta = {}
|
||
for kb_id, benchmarks in benchmarks_meta.items():
|
||
if kb_id in self.databases_meta:
|
||
self.benchmarks_meta[kb_id] = benchmarks
|
||
|
||
self._normalize_metadata_state()
|
||
self._metadata_loaded = True
|
||
logger.info(f"{self.kb_type}: 加载了 {len(self.databases_meta)} 个数据库的元数据")
|
||
|
||
def _ensure_metadata_loaded(self):
|
||
"""确保元数据已加载(延迟加载)"""
|
||
if not self._metadata_loaded:
|
||
logger.warning(f"{self.kb_type}: 元数据尚未加载,请确保 KnowledgeBaseManager 已调用 load_metadata()")
|
||
|
||
@staticmethod
|
||
def _normalize_timestamp(value: Any) -> str | None:
|
||
"""Convert persisted timestamps to a normalized UTC ISO string."""
|
||
try:
|
||
dt_value = coerce_any_to_utc_datetime(value)
|
||
except (TypeError, ValueError) as exc: # noqa: BLE001
|
||
logger.warning(f"Invalid timestamp encountered: {value!r} ({exc})")
|
||
return None
|
||
|
||
if not dt_value:
|
||
return None
|
||
return utc_isoformat(dt_value)
|
||
|
||
def _normalize_metadata_state(self) -> None:
|
||
"""Ensure in-memory metadata uses normalized timestamp formats."""
|
||
for meta in self.databases_meta.values():
|
||
if "created_at" in meta:
|
||
normalized = self._normalize_timestamp(meta.get("created_at"))
|
||
if normalized:
|
||
meta["created_at"] = normalized
|
||
|
||
for file_info in self.files_meta.values():
|
||
if "created_at" in file_info:
|
||
normalized = self._normalize_timestamp(file_info.get("created_at"))
|
||
if normalized:
|
||
file_info["created_at"] = normalized
|
||
|
||
for db_benchmarks in self.benchmarks_meta.values():
|
||
for b in db_benchmarks.values():
|
||
if "created_at" in b:
|
||
normalized = self._normalize_timestamp(b.get("created_at"))
|
||
if normalized:
|
||
b["created_at"] = normalized
|
||
if "updated_at" in b:
|
||
normalized = self._normalize_timestamp(b.get("updated_at"))
|
||
if normalized:
|
||
b["updated_at"] = normalized
|
||
|
||
@classmethod
|
||
def get_create_params_config(cls) -> dict[str, Any]:
|
||
"""获取创建知识库时的类型特定参数配置。"""
|
||
return {"options": []}
|
||
|
||
@classmethod
|
||
def validate_additional_params(cls, additional_params: dict | None) -> dict:
|
||
"""校验并规范化类型特定配置。"""
|
||
return dict(additional_params or {})
|
||
|
||
@classmethod
|
||
def normalize_additional_params(cls, additional_params: dict | None) -> dict:
|
||
"""规范化 additional_params,仅文档型知识库补充分块默认值。"""
|
||
params = cls.validate_additional_params(additional_params)
|
||
if cls.apply_chunk_defaults:
|
||
return ensure_chunk_defaults_in_additional_params(params)
|
||
return params
|
||
|
||
@abstractmethod
|
||
async def _create_kb_instance(self, kb_id: str, config: dict) -> Any:
|
||
"""
|
||
创建底层知识库实例
|
||
|
||
Args:
|
||
kb_id: 数据库ID
|
||
config: 配置信息
|
||
|
||
Returns:
|
||
底层知识库实例
|
||
"""
|
||
pass
|
||
|
||
@abstractmethod
|
||
async def _initialize_kb_instance(self, instance: Any) -> None:
|
||
"""
|
||
初始化底层知识库实例
|
||
|
||
Args:
|
||
instance: 底层知识库实例
|
||
"""
|
||
pass
|
||
|
||
async def add_file_record(
|
||
self, kb_id: str, item: str, params: dict | None = None, operator_id: str | None = None
|
||
) -> dict:
|
||
"""
|
||
Add a file record to metadata (Status: UPLOADED)
|
||
|
||
Args:
|
||
kb_id: Database ID
|
||
item: File path or URL
|
||
params: Parameters
|
||
operator_id: Operator ID who created the file
|
||
|
||
Returns:
|
||
File metadata record
|
||
"""
|
||
from yuxi.knowledge.utils.kb_utils import prepare_item_metadata
|
||
|
||
params = params or {}
|
||
content_type = params.get("content_type", "file")
|
||
|
||
# Prepare metadata
|
||
metadata = await prepare_item_metadata(item, content_type, kb_id, params=params)
|
||
file_id = metadata["file_id"]
|
||
kb_additional_params = self.databases_meta.get(kb_id, {}).get("metadata") or {}
|
||
metadata["processing_params"] = resolve_processing_params(
|
||
kb_additional_params=kb_additional_params,
|
||
file_processing_params=metadata.get("processing_params"),
|
||
)
|
||
|
||
# Fallback: fetch file size from MinIO if not provided
|
||
if metadata.get("size") is None and content_type == "file":
|
||
try:
|
||
from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url
|
||
from yuxi.storage.minio import get_minio_client
|
||
|
||
file_path = metadata.get("path") or item
|
||
if is_minio_url(file_path):
|
||
bucket_name, obj_name = parse_minio_url(file_path)
|
||
minio_client = get_minio_client()
|
||
file_size = await minio_client.astat_file(bucket_name, obj_name)
|
||
if file_size is not None:
|
||
metadata["size"] = file_size
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to stat file size from MinIO for {item}: {exc}")
|
||
|
||
# Initial status
|
||
metadata["status"] = FileStatus.UPLOADED
|
||
metadata["created_at"] = utc_isoformat()
|
||
if operator_id:
|
||
metadata["created_by"] = operator_id
|
||
|
||
# Save to metadata
|
||
self.files_meta[file_id] = metadata
|
||
await self._persist_file(file_id)
|
||
|
||
return metadata
|
||
|
||
async def parse_file(self, kb_id: str, file_id: str, operator_id: str | None = None) -> dict:
|
||
"""
|
||
Parse file to Markdown and save to MinIO (Status: PARSING -> PARSED/ERROR_PARSING)
|
||
|
||
Args:
|
||
kb_id: Database ID
|
||
file_id: File ID
|
||
operator_id: ID of the user performing the operation
|
||
|
||
Returns:
|
||
Updated file metadata
|
||
"""
|
||
if file_id not in self.files_meta:
|
||
raise ValueError(f"File {file_id} not found")
|
||
|
||
file_meta = self.files_meta[file_id]
|
||
current_status = file_meta.get("status")
|
||
|
||
# Validate current status - only allow parsing from these states
|
||
allowed_statuses = {
|
||
FileStatus.UPLOADED,
|
||
FileStatus.ERROR_PARSING,
|
||
"failed", # Legacy status
|
||
}
|
||
|
||
if current_status not in allowed_statuses:
|
||
raise ValueError(
|
||
f"Cannot parse file with status '{current_status}'. "
|
||
f"File must be in one of these states: {', '.join(allowed_statuses)}"
|
||
)
|
||
|
||
file_path = file_meta.get("path")
|
||
if not file_path:
|
||
raise ValueError(f"File {file_id} has no valid path in metadata")
|
||
|
||
# Clear previous error if any
|
||
if "error" in file_meta:
|
||
self.files_meta[file_id].pop("error", None)
|
||
|
||
# Update status to PARSING and add to processing queue
|
||
self.files_meta[file_id]["status"] = FileStatus.PARSING
|
||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||
if operator_id:
|
||
self.files_meta[file_id]["updated_by"] = operator_id
|
||
await self._persist_file(file_id)
|
||
|
||
# Add to processing queue
|
||
self._add_to_processing_queue(file_id)
|
||
|
||
try:
|
||
from yuxi.knowledge.parser.unified import Parser
|
||
|
||
# Prepare params
|
||
params = file_meta.get("processing_params", {}) or {}
|
||
params["image_bucket"] = "public"
|
||
params["image_prefix"] = f"{kb_id}/kb-images"
|
||
|
||
markdown_content = await Parser.aparse(
|
||
source=file_path,
|
||
params=params,
|
||
)
|
||
|
||
# Save Markdown to MinIO
|
||
markdown_file_path = await self._save_markdown_to_minio(kb_id, file_id, markdown_content)
|
||
|
||
# Update metadata
|
||
self.files_meta[file_id]["status"] = FileStatus.PARSED
|
||
self.files_meta[file_id]["markdown_file"] = markdown_file_path
|
||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||
if operator_id:
|
||
self.files_meta[file_id]["updated_by"] = operator_id
|
||
await self._persist_file(file_id)
|
||
|
||
return self.files_meta[file_id]
|
||
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
logger.error(f"Failed to parse file {file_id}: {error_msg}")
|
||
|
||
self.files_meta[file_id]["status"] = FileStatus.ERROR_PARSING
|
||
self.files_meta[file_id]["error"] = error_msg
|
||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||
if operator_id:
|
||
self.files_meta[file_id]["updated_by"] = operator_id
|
||
await self._persist_file(file_id)
|
||
|
||
raise
|
||
|
||
finally:
|
||
# Remove from processing queue
|
||
self._remove_from_processing_queue(file_id)
|
||
|
||
async def update_file_params(self, kb_id: str, file_id: str, params: dict, operator_id: str | None = None) -> None:
|
||
"""Update file processing params"""
|
||
if file_id not in self.files_meta:
|
||
raise ValueError(f"File {file_id} not found")
|
||
|
||
# Skip if no params to update
|
||
if not params:
|
||
return
|
||
|
||
current_params = self.files_meta[file_id].get("processing_params", {}) or {}
|
||
kb_additional_params = self.databases_meta.get(kb_id, {}).get("metadata") or {}
|
||
|
||
logger.debug(f"[update_file_params] file_id={file_id}, current_params={current_params}, new_params={params}")
|
||
|
||
current_params = resolve_processing_params(
|
||
kb_additional_params=kb_additional_params,
|
||
file_processing_params=current_params,
|
||
request_params=params,
|
||
)
|
||
|
||
self.files_meta[file_id]["processing_params"] = current_params
|
||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||
if operator_id:
|
||
self.files_meta[file_id]["updated_by"] = operator_id
|
||
|
||
logger.debug(f"[update_file_params] file_id={file_id}, updated_params={current_params}")
|
||
|
||
await self._persist_file(file_id)
|
||
|
||
async def _mark_file_unparsed(self, file_id: str, operator_id: str | None = None) -> None:
|
||
if file_id not in self.files_meta:
|
||
return
|
||
|
||
self.files_meta[file_id]["status"] = FileStatus.UPLOADED
|
||
self.files_meta[file_id].pop("markdown_file", None)
|
||
self.files_meta[file_id].pop("error", None)
|
||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||
if operator_id:
|
||
self.files_meta[file_id]["updated_by"] = operator_id
|
||
await self._persist_file(file_id)
|
||
|
||
async def _save_markdown_to_minio(self, kb_id: str, file_id: str, content: str) -> str:
|
||
"""Save markdown content to MinIO and return HTTP URL"""
|
||
from yuxi.storage.minio import get_minio_client
|
||
|
||
minio_client = get_minio_client()
|
||
bucket_name = minio_client.KB_BUCKETS["parsed"]
|
||
await asyncio.to_thread(minio_client.ensure_bucket_exists, bucket_name)
|
||
|
||
object_name = f"{kb_id}/parsed/{file_id}.md"
|
||
data = content.encode("utf-8")
|
||
|
||
# Return standard HTTP URL from UploadResult
|
||
upload_result = await minio_client.aupload_file(
|
||
bucket_name=bucket_name,
|
||
object_name=object_name,
|
||
data=data,
|
||
)
|
||
|
||
return upload_result.url
|
||
|
||
async def _read_minio_bytes(self, file_path: str) -> bytes:
|
||
from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url
|
||
from yuxi.storage.minio import get_minio_client
|
||
|
||
if not file_path or not is_minio_url(file_path):
|
||
raise ValueError(f"Invalid MinIO path format: {file_path}")
|
||
|
||
bucket_name, object_name = parse_minio_url(file_path)
|
||
minio_client = get_minio_client()
|
||
return await minio_client.adownload_file(bucket_name, object_name)
|
||
|
||
async def _read_markdown_from_minio(self, file_path: str) -> str:
|
||
"""Read markdown content from MinIO"""
|
||
content_bytes = await self._read_minio_bytes(file_path)
|
||
return content_bytes.decode("utf-8")
|
||
|
||
def _get_file_meta(self, kb_id: str, file_id: str) -> dict:
|
||
file_meta = self.files_meta.get(file_id)
|
||
if not file_meta or file_meta.get("kb_id") != kb_id:
|
||
raise ValueError(f"File {file_id} not found")
|
||
return file_meta
|
||
|
||
@staticmethod
|
||
def _original_file_path(file_meta: dict) -> str | None:
|
||
return file_meta.get("minio_url") or file_meta.get("path")
|
||
|
||
def _knowledge_preview_variants(self, file_meta: dict) -> list[dict]:
|
||
variants = []
|
||
original_path = self._original_file_path(file_meta)
|
||
if original_path:
|
||
variants.append({"key": "original", "label": "Source", "supported": True})
|
||
if file_meta.get("markdown_file"):
|
||
variants.append({"key": "parsed", "label": "MD", "supported": True})
|
||
return variants
|
||
|
||
def _knowledge_file_entry(self, kb_id: str, file_id: str, file_meta: dict) -> dict:
|
||
is_dir = bool(file_meta.get("is_folder"))
|
||
variants = [] if is_dir else self._knowledge_preview_variants(file_meta)
|
||
preview_modes = [item["key"] for item in variants]
|
||
default_preview_mode = None
|
||
if "parsed" in preview_modes:
|
||
default_preview_mode = "parsed"
|
||
elif preview_modes:
|
||
default_preview_mode = preview_modes[0]
|
||
path = f"/{file_id}"
|
||
if is_dir:
|
||
path = f"{path}/"
|
||
return {
|
||
"source": "knowledge",
|
||
"kb_id": kb_id,
|
||
"file_id": file_id,
|
||
"parent_id": file_meta.get("parent_id"),
|
||
"path": path,
|
||
"virtual_path": f"/knowledge/{kb_id}/{file_id}",
|
||
"name": file_meta.get("filename") or file_meta.get("original_filename") or file_id,
|
||
"is_dir": is_dir,
|
||
"size": 0 if is_dir else file_meta.get("size") or 0,
|
||
"modified_at": file_meta.get("updated_at") or file_meta.get("created_at") or "",
|
||
"readonly": True,
|
||
"status": file_meta.get("status", "done"),
|
||
"preview_modes": preview_modes,
|
||
"default_preview_mode": default_preview_mode,
|
||
}
|
||
|
||
def _sort_file_entries(self, entries: list[dict]) -> list[dict]:
|
||
return sorted(
|
||
entries,
|
||
key=lambda item: (not bool(item.get("is_dir")), str(item.get("name") or "").lower()),
|
||
)
|
||
|
||
def _list_knowledge_children(
|
||
self,
|
||
kb_id: str,
|
||
parent_id: str | None,
|
||
*,
|
||
recursive: bool,
|
||
files_only: bool,
|
||
) -> list[dict]:
|
||
children = [
|
||
(file_id, meta)
|
||
for file_id, meta in self.files_meta.items()
|
||
if meta.get("kb_id") == kb_id and meta.get("parent_id") == parent_id
|
||
]
|
||
entries = []
|
||
for file_id, meta in children:
|
||
if not files_only or not meta.get("is_folder"):
|
||
entries.append(self._knowledge_file_entry(kb_id, file_id, meta))
|
||
if recursive and meta.get("is_folder"):
|
||
entries.extend(
|
||
self._list_knowledge_children(
|
||
kb_id,
|
||
file_id,
|
||
recursive=True,
|
||
files_only=files_only,
|
||
)
|
||
)
|
||
return self._sort_file_entries(entries)
|
||
|
||
async def list_file_tree(
|
||
self,
|
||
kb_id: str,
|
||
parent_id: str | None = None,
|
||
recursive: bool = False,
|
||
files_only: bool = False,
|
||
) -> dict:
|
||
if kb_id not in self.databases_meta:
|
||
raise ValueError(f"Database {kb_id} not found")
|
||
if parent_id:
|
||
parent_meta = self._get_file_meta(kb_id, parent_id)
|
||
if not parent_meta.get("is_folder"):
|
||
raise ValueError("Parent is not a folder")
|
||
return {
|
||
"entries": self._list_knowledge_children(
|
||
kb_id,
|
||
parent_id,
|
||
recursive=recursive,
|
||
files_only=files_only,
|
||
),
|
||
"readonly": True,
|
||
}
|
||
|
||
async def read_file_preview(self, kb_id: str, file_id: str, variant: str = "parsed") -> dict:
|
||
from yuxi.services.viewer_filesystem_service import _detect_preview_type
|
||
|
||
file_meta = self._get_file_meta(kb_id, file_id)
|
||
if file_meta.get("is_folder"):
|
||
raise ValueError("Cannot preview a folder")
|
||
|
||
variants = self._knowledge_preview_variants(file_meta)
|
||
variant_keys = {item["key"] for item in variants}
|
||
if variant not in {"original", "parsed"}:
|
||
raise ValueError("Unsupported preview variant")
|
||
|
||
filename = file_meta.get("filename") or file_meta.get("original_filename") or file_id
|
||
response = {
|
||
"source": "knowledge",
|
||
"kb_id": kb_id,
|
||
"file_id": file_id,
|
||
"filename": filename,
|
||
"variant": variant,
|
||
"readonly": True,
|
||
"available_variants": variants,
|
||
}
|
||
|
||
if variant == "parsed":
|
||
markdown_file = file_meta.get("markdown_file")
|
||
if not markdown_file or "parsed" not in variant_keys:
|
||
return {
|
||
**response,
|
||
"content": None,
|
||
"preview_type": "unsupported",
|
||
"supported": False,
|
||
"message": "文件尚未生成解析结果",
|
||
}
|
||
content = await self._read_markdown_from_minio(markdown_file)
|
||
return {
|
||
**response,
|
||
"content": content,
|
||
"preview_type": "markdown",
|
||
"supported": True,
|
||
"message": None,
|
||
}
|
||
|
||
original_path = self._original_file_path(file_meta)
|
||
if not original_path or "original" not in variant_keys:
|
||
return {
|
||
**response,
|
||
"content": None,
|
||
"preview_type": "unsupported",
|
||
"supported": False,
|
||
"message": "文件没有可预览的原始内容",
|
||
}
|
||
|
||
preview_type, supported, message = _detect_preview_type(filename, b"")
|
||
if preview_type in {"image", "pdf"}:
|
||
return {
|
||
**response,
|
||
"content": None,
|
||
"preview_type": preview_type,
|
||
"supported": supported,
|
||
"message": message,
|
||
}
|
||
|
||
raw_content = await self._read_minio_bytes(original_path)
|
||
preview_type, supported, message = _detect_preview_type(filename, raw_content)
|
||
if preview_type in {"image", "pdf"} or not supported:
|
||
return {
|
||
**response,
|
||
"content": None,
|
||
"preview_type": preview_type,
|
||
"supported": supported,
|
||
"message": message,
|
||
}
|
||
try:
|
||
content = raw_content.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
return {
|
||
**response,
|
||
"content": None,
|
||
"preview_type": "unsupported",
|
||
"supported": False,
|
||
"message": "当前文件不是 UTF-8 文本,暂不支持预览",
|
||
}
|
||
return {
|
||
**response,
|
||
"content": content,
|
||
"preview_type": preview_type,
|
||
"supported": True,
|
||
"message": message,
|
||
}
|
||
|
||
async def get_file_download(self, kb_id: str, file_id: str, variant: str = "original") -> dict:
|
||
file_meta = self._get_file_meta(kb_id, file_id)
|
||
if file_meta.get("is_folder"):
|
||
raise ValueError("Cannot download a folder")
|
||
if variant not in {"original", "parsed"}:
|
||
raise ValueError("Unsupported download variant")
|
||
|
||
filename = file_meta.get("filename") or file_meta.get("original_filename") or file_id
|
||
if variant == "parsed":
|
||
markdown_file = file_meta.get("markdown_file")
|
||
if not markdown_file:
|
||
raise ValueError("文件尚未生成解析结果")
|
||
return {
|
||
"filename": f"{filename}.parsed.md",
|
||
"content": await self._read_minio_bytes(markdown_file),
|
||
"media_type": "text/markdown; charset=utf-8",
|
||
}
|
||
|
||
original_path = self._original_file_path(file_meta)
|
||
if not original_path:
|
||
raise ValueError("文件没有可下载的原始内容")
|
||
media_type = file_meta.get("content_type") or mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||
return {
|
||
"filename": filename,
|
||
"content": await self._read_minio_bytes(original_path),
|
||
"media_type": media_type,
|
||
}
|
||
|
||
def _build_open_file_window(self, content: str, *, offset: int = 0, limit: int = 800) -> dict[str, Any]:
|
||
lines = content.splitlines()
|
||
total_lines = len(lines)
|
||
start = min(max(int(offset), 0), total_lines)
|
||
window_size = min(max(int(limit), 1), 2000)
|
||
selected = lines[start : start + window_size]
|
||
end = start + len(selected)
|
||
|
||
return {
|
||
"start_line": start + 1 if selected else 0,
|
||
"end_line": end,
|
||
"total_lines": total_lines,
|
||
"offset": start,
|
||
"window_size": window_size,
|
||
"has_more_before": start > 0,
|
||
"has_more_after": end < total_lines,
|
||
"next_offset": end if end < total_lines else None,
|
||
"content": "\n".join(f"{start + idx + 1:6d}\t{line}" for idx, line in enumerate(selected)),
|
||
}
|
||
|
||
@staticmethod
|
||
def build_search_output(kb_id: str, retrieval_results: Any) -> dict[str, Any] | Any:
|
||
if not isinstance(retrieval_results, list):
|
||
return retrieval_results
|
||
|
||
results = []
|
||
for index, chunk in enumerate(retrieval_results):
|
||
if not isinstance(chunk, dict):
|
||
continue
|
||
|
||
metadata = chunk.get("metadata") if isinstance(chunk.get("metadata"), dict) else {}
|
||
metadata = {
|
||
key: value
|
||
for key, value in metadata.items()
|
||
if key not in {"filepath", "parsed_path", "path", "markdown_file"}
|
||
}
|
||
file_id = metadata.get("file_id") or chunk.get("file_id") or chunk.get("full_doc_id") or ""
|
||
chunk_id = metadata.get("chunk_id") or chunk.get("chunk_id") or chunk.get("id")
|
||
chunk_index = metadata.get("chunk_index")
|
||
if chunk_index is None:
|
||
chunk_index = chunk.get("chunk_index")
|
||
if chunk_index is not None:
|
||
metadata.setdefault("chunk_index", chunk_index)
|
||
if chunk.get("score") is not None:
|
||
metadata.setdefault("score", chunk.get("score"))
|
||
if chunk.get("distance") is not None:
|
||
metadata.setdefault("distance", chunk.get("distance"))
|
||
|
||
results.append(
|
||
SearchResultSchema(
|
||
id=str(chunk_id or f"{file_id}:{index + 1}"),
|
||
kb_id=str(kb_id),
|
||
file_id=str(file_id or ""),
|
||
content=str(chunk.get("content") or ""),
|
||
metadata=metadata,
|
||
)
|
||
)
|
||
|
||
return SearchOutputSchema(kb_id=str(kb_id), results=results).model_dump()
|
||
|
||
@staticmethod
|
||
def _build_find_file_windows(
|
||
content: str,
|
||
*,
|
||
patterns: list[str],
|
||
use_regex: bool = False,
|
||
case_sensitive: bool = False,
|
||
max_windows: int = 5,
|
||
window_size: int = 80,
|
||
) -> dict[str, Any]:
|
||
patterns = [pattern for pattern in patterns if pattern]
|
||
if not patterns:
|
||
raise ValueError("请提供至少一个 pattern")
|
||
|
||
lines = content.splitlines()
|
||
flags = 0 if case_sensitive else re.IGNORECASE
|
||
if use_regex:
|
||
matchers = [re.compile(pattern, flags) for pattern in patterns]
|
||
|
||
def line_matches(line: str) -> bool:
|
||
return any(matcher.search(line) for matcher in matchers)
|
||
|
||
else:
|
||
normalized_patterns = patterns if case_sensitive else [pattern.lower() for pattern in patterns]
|
||
|
||
def line_matches(line: str) -> bool:
|
||
haystack = line if case_sensitive else line.lower()
|
||
return any(pattern in haystack for pattern in normalized_patterns)
|
||
|
||
matched_indexes = [index for index, line in enumerate(lines) if line_matches(line)]
|
||
windows: list[FindWindowSchema] = []
|
||
covered_until = -1
|
||
normalized_window_size = min(max(int(window_size), 1), 200)
|
||
half_window = normalized_window_size // 2
|
||
|
||
for matched_index in matched_indexes:
|
||
if matched_index < covered_until:
|
||
continue
|
||
start = max(matched_index - half_window, 0)
|
||
end = min(start + normalized_window_size, len(lines))
|
||
start = max(end - normalized_window_size, 0)
|
||
matched_lines = [index + 1 for index in matched_indexes if start <= index < end]
|
||
selected = lines[start:end]
|
||
windows.append(
|
||
FindWindowSchema(
|
||
start_line=start + 1 if selected else 0,
|
||
end_line=end,
|
||
matched_lines=matched_lines,
|
||
content="\n".join(f"{start + idx + 1:6d}\t{line}" for idx, line in enumerate(selected)),
|
||
)
|
||
)
|
||
covered_until = end
|
||
if len(windows) >= max_windows:
|
||
break
|
||
|
||
return FindOutputSchema(
|
||
kb_id="",
|
||
file_id="",
|
||
semantic=False,
|
||
match_mode="regex" if use_regex else "keyword",
|
||
total_matches=len(matched_indexes),
|
||
windows=windows,
|
||
).model_dump(exclude={"kb_id", "file_id"})
|
||
|
||
async def open_file_content(self, kb_id: str, file_id: str, offset: int = 0, limit: int = 800) -> dict:
|
||
"""按行窗口打开文件解析后的 Markdown 内容"""
|
||
file_meta = self.files_meta.get(file_id)
|
||
if file_meta is None:
|
||
raise Exception(f"文件不存在: {file_id}")
|
||
if file_meta.get("kb_id") != kb_id:
|
||
raise Exception(f"文件 {file_id} 不属于知识库 {kb_id}")
|
||
if file_meta.get("is_folder"):
|
||
raise Exception(f"文件 {file_id} 是文件夹")
|
||
|
||
markdown_file = file_meta.get("markdown_file")
|
||
if not markdown_file:
|
||
raise Exception(f"文件 {file_id} 没有解析后的 Markdown 内容")
|
||
|
||
content = await self._read_markdown_from_minio(markdown_file)
|
||
return self._build_open_file_window(content, offset=offset, limit=limit)
|
||
|
||
async def find_file_content(
|
||
self,
|
||
kb_id: str,
|
||
file_id: str,
|
||
patterns: list[str],
|
||
*,
|
||
use_regex: bool = False,
|
||
case_sensitive: bool = False,
|
||
max_windows: int = 5,
|
||
window_size: int = 80,
|
||
) -> dict:
|
||
file_meta = self.files_meta.get(file_id)
|
||
if file_meta is None:
|
||
raise Exception(f"文件不存在: {file_id}")
|
||
if file_meta.get("kb_id") != kb_id:
|
||
raise Exception(f"文件 {file_id} 不属于知识库 {kb_id}")
|
||
if file_meta.get("is_folder"):
|
||
raise Exception(f"文件 {file_id} 是文件夹")
|
||
|
||
markdown_file = file_meta.get("markdown_file")
|
||
if not markdown_file:
|
||
raise Exception(f"文件 {file_id} 没有解析后的 Markdown 内容")
|
||
|
||
content = await self._read_markdown_from_minio(markdown_file)
|
||
return self._build_find_file_windows(
|
||
content,
|
||
patterns=patterns,
|
||
use_regex=use_regex,
|
||
case_sensitive=case_sensitive,
|
||
max_windows=max_windows,
|
||
window_size=window_size,
|
||
)
|
||
|
||
@abstractmethod
|
||
async def index_file(self, kb_id: str, file_id: str, operator_id: str | None = None) -> dict:
|
||
"""
|
||
Index parsed file (Status: INDEXING -> INDEXED/ERROR_INDEXING)
|
||
|
||
Args:
|
||
kb_id: Database ID
|
||
file_id: File ID
|
||
operator_id: ID of the user performing the operation
|
||
|
||
Returns:
|
||
Updated file metadata
|
||
"""
|
||
pass
|
||
|
||
async def create_database(
|
||
self,
|
||
database_name: str,
|
||
description: str,
|
||
embedding_model_spec: str | None = None,
|
||
llm_model_spec: str | None = None,
|
||
**kwargs,
|
||
) -> dict:
|
||
"""
|
||
创建数据库
|
||
|
||
Args:
|
||
database_name: 数据库名称
|
||
description: 数据库描述
|
||
embedding_model_spec: 嵌入模型 spec
|
||
llm_model_spec: LLM 模型 spec
|
||
**kwargs: 其他配置参数
|
||
|
||
Returns:
|
||
数据库信息字典
|
||
"""
|
||
kwargs = self.normalize_additional_params(kwargs)
|
||
|
||
alphabet = string.ascii_lowercase + string.digits
|
||
while True:
|
||
kb_id = "kb_" + "".join(secrets.choice(alphabet) for _ in range(10))
|
||
if kb_id not in self.databases_meta:
|
||
break
|
||
|
||
self.databases_meta[kb_id] = {
|
||
"name": database_name,
|
||
"description": description,
|
||
"kb_type": self.kb_type,
|
||
"embedding_model_spec": embedding_model_spec,
|
||
"llm_model_spec": llm_model_spec,
|
||
"metadata": kwargs,
|
||
"created_at": utc_isoformat(),
|
||
"query_params": self._get_default_query_params(kb_id),
|
||
}
|
||
await self._persist_kb(kb_id)
|
||
|
||
# 创建工作目录
|
||
working_dir = os.path.join(self.work_dir, kb_id)
|
||
os.makedirs(working_dir, exist_ok=True)
|
||
|
||
# 返回数据库信息
|
||
db_dict = self.databases_meta[kb_id].copy()
|
||
db_dict["kb_id"] = kb_id
|
||
db_dict["files"] = {}
|
||
|
||
return db_dict
|
||
|
||
async def delete_database(self, kb_id: str) -> dict:
|
||
"""
|
||
删除数据库
|
||
|
||
Args:
|
||
kb_id: 数据库ID
|
||
|
||
Returns:
|
||
操作结果
|
||
"""
|
||
if kb_id in self.databases_meta:
|
||
from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url
|
||
from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository
|
||
from yuxi.storage.minio import get_minio_client
|
||
|
||
minio_client = get_minio_client()
|
||
|
||
# 1. 删除文件元数据中记录的 MinIO 文件
|
||
files_to_delete = [fid for fid, finfo in self.files_meta.items() if finfo.get("kb_id") == kb_id]
|
||
for file_id in files_to_delete:
|
||
file_path = self.files_meta[file_id].get("path")
|
||
if file_path and 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 e:
|
||
logger.warning(f"Failed to delete MinIO file {file_path}: {e}")
|
||
|
||
# 删除解析后的 markdown 文件
|
||
parsed_object = f"{kb_id}/parsed/{file_id}.md"
|
||
await minio_client.adelete_file(minio_client.KB_BUCKETS["parsed"], parsed_object)
|
||
|
||
del self.files_meta[file_id]
|
||
|
||
# 2. 并行删除所有知识库 bucket 中该 kb_id 下的文件
|
||
prefix = f"{kb_id}/"
|
||
cleanup_buckets = {
|
||
minio_client.KB_BUCKETS["parsed"],
|
||
minio_client.KB_BUCKETS["documents"],
|
||
minio_client.KB_BUCKETS["images"],
|
||
}
|
||
cleanup_tasks = [
|
||
minio_client.adelete_objects_by_prefix(bucket_name, prefix) for bucket_name in cleanup_buckets
|
||
]
|
||
await asyncio.gather(*cleanup_tasks)
|
||
|
||
# 3. 删除数据库记录
|
||
del self.databases_meta[kb_id]
|
||
kb_repo = KnowledgeBaseRepository()
|
||
await kb_repo.delete(kb_id)
|
||
await self._save_metadata()
|
||
|
||
# 删除工作目录
|
||
working_dir = os.path.join(self.work_dir, kb_id)
|
||
if os.path.exists(working_dir):
|
||
import shutil
|
||
|
||
try:
|
||
shutil.rmtree(working_dir)
|
||
except Exception as e:
|
||
logger.error(f"Error deleting working directory {working_dir}: {e}")
|
||
|
||
return {"message": "删除成功"}
|
||
|
||
async def create_folder(self, kb_id: str, folder_name: str, parent_id: str | None = None) -> dict:
|
||
"""Create a folder in the database."""
|
||
import uuid
|
||
|
||
folder_id = f"folder-{uuid.uuid4()}"
|
||
|
||
self.files_meta[folder_id] = {
|
||
"file_id": folder_id,
|
||
"filename": folder_name,
|
||
"is_folder": True,
|
||
"parent_id": parent_id,
|
||
"kb_id": kb_id,
|
||
"created_at": utc_isoformat(),
|
||
"status": "done",
|
||
"path": folder_name,
|
||
"file_type": "folder",
|
||
}
|
||
await self._persist_file(folder_id)
|
||
return self.files_meta[folder_id]
|
||
|
||
@abstractmethod
|
||
async def update_content(self, kb_id: str, file_ids: list[str], params: dict | None = None) -> list[dict]:
|
||
"""
|
||
更新内容 - 根据file_ids重新解析文件并更新向量库
|
||
|
||
Args:
|
||
kb_id: 数据库ID
|
||
file_ids: 文件ID列表
|
||
params: 处理参数
|
||
|
||
Returns:
|
||
更新结果列表
|
||
"""
|
||
pass
|
||
|
||
@abstractmethod
|
||
async def aquery(self, query_text: str, kb_id: str, **kwargs) -> list[dict]:
|
||
"""
|
||
异步查询知识库
|
||
|
||
Args:
|
||
query_text: 查询文本
|
||
kb_id: 数据库ID
|
||
**kwargs: 查询参数
|
||
|
||
Returns:
|
||
一个包含字典的列表,每个字典代表一个检索到的文档块。
|
||
"""
|
||
pass
|
||
|
||
@abstractmethod
|
||
def get_query_params_config(self, kb_id: str, **kwargs) -> dict:
|
||
"""
|
||
获取知识库类型的查询参数配置
|
||
|
||
Args:
|
||
kb_id: 数据库ID
|
||
**kwargs: 额外参数
|
||
|
||
Returns:
|
||
dict: {
|
||
"type": "kb_type",
|
||
"options": [
|
||
{
|
||
"key": "param_name",
|
||
"label": "参数名称",
|
||
"type": "select|number|boolean",
|
||
"default": default_value,
|
||
"options": [...], # 对于 select 类型
|
||
"description": "参数描述",
|
||
"min": 1, # 对于 number 类型
|
||
"max": 100,
|
||
"step": 0.1
|
||
},
|
||
...
|
||
]
|
||
}
|
||
"""
|
||
pass
|
||
|
||
async def export_data(self, kb_id: str, format: str = "zip", **kwargs) -> str:
|
||
pass
|
||
|
||
def _get_query_params(self, kb_id: str) -> dict:
|
||
"""从实例元数据中加载查询参数"""
|
||
if kb_id in self.databases_meta:
|
||
query_params_meta = self.databases_meta[kb_id].get("query_params") or {}
|
||
return query_params_meta.get("options", {})
|
||
return {}
|
||
|
||
def _get_default_query_params(self, kb_id: str) -> dict[str, Any]:
|
||
"""从 get_query_params_config 中提取所有参数的默认值,返回 {"options": {...}}"""
|
||
config = self.get_query_params_config(kb_id)
|
||
defaults = {}
|
||
for opt in config.get("options", []):
|
||
if "default" in opt:
|
||
defaults[opt["key"]] = opt["default"]
|
||
return {"options": defaults}
|
||
|
||
def get_database_info(self, kb_id: str, include_files: bool = True) -> dict | None:
|
||
"""
|
||
获取数据库详细信息
|
||
|
||
Args:
|
||
kb_id: 数据库ID
|
||
include_files: 是否包含文件信息,默认为True
|
||
|
||
Returns:
|
||
数据库信息或None
|
||
"""
|
||
if kb_id not in self.databases_meta:
|
||
return None
|
||
|
||
meta = self.databases_meta[kb_id].copy()
|
||
meta["kb_id"] = kb_id
|
||
|
||
# 检查并修复异常的processing状态
|
||
self._check_and_fix_processing_status(kb_id)
|
||
|
||
# 统计文件数量(始终计算,即使不加载文件详情)
|
||
db_file_count = sum(1 for file_info in self.files_meta.values() if file_info.get("kb_id") == kb_id)
|
||
meta["row_count"] = db_file_count
|
||
|
||
# 仅在需要时加载文件详情
|
||
if include_files:
|
||
db_files = {}
|
||
for file_id, file_info in self.files_meta.items():
|
||
if file_info.get("kb_id") == kb_id:
|
||
created_at = self._normalize_timestamp(file_info.get("created_at"))
|
||
db_files[file_id] = {
|
||
"file_id": file_id,
|
||
"filename": file_info.get("filename", ""),
|
||
"path": file_info.get("path", ""),
|
||
"markdown_file": file_info.get("markdown_file", ""),
|
||
"type": file_info.get("file_type", ""),
|
||
"status": file_info.get("status", "done"),
|
||
"created_at": created_at,
|
||
"is_folder": file_info.get("is_folder", False),
|
||
"parent_id": file_info.get("parent_id", None),
|
||
}
|
||
|
||
# 按创建时间倒序排序文件列表
|
||
sorted_files = dict(
|
||
sorted(
|
||
db_files.items(),
|
||
key=lambda item: item[1].get("created_at") or "",
|
||
reverse=True,
|
||
)
|
||
)
|
||
|
||
meta["files"] = sorted_files
|
||
|
||
meta["status"] = "已连接"
|
||
return meta
|
||
|
||
def get_databases(self, include_files: bool = False) -> dict:
|
||
"""
|
||
获取所有数据库信息
|
||
|
||
Args:
|
||
include_files: 是否包含文件信息,默认False以减少响应大小
|
||
|
||
Returns:
|
||
数据库列表
|
||
"""
|
||
# 确保元数据已加载(延迟加载机制)
|
||
self._ensure_metadata_loaded()
|
||
|
||
databases = []
|
||
for kb_id, meta in self.databases_meta.items():
|
||
# 检查并修复异常的processing状态
|
||
self._check_and_fix_processing_status(kb_id)
|
||
|
||
db_dict = meta.copy()
|
||
db_dict["kb_id"] = kb_id
|
||
|
||
# 统计文件数量(始终计算,即使不加载文件详情)
|
||
db_file_count = sum(1 for file_info in self.files_meta.values() if file_info.get("kb_id") == kb_id)
|
||
db_dict["row_count"] = db_file_count
|
||
|
||
# 仅在需要时加载文件详情
|
||
if include_files:
|
||
db_files = {}
|
||
for file_id, file_info in self.files_meta.items():
|
||
if file_info.get("kb_id") == kb_id:
|
||
created_at = self._normalize_timestamp(file_info.get("created_at"))
|
||
db_files[file_id] = {
|
||
"file_id": file_id,
|
||
"filename": file_info.get("filename", ""),
|
||
"path": file_info.get("path", ""),
|
||
"markdown_file": file_info.get("markdown_file", ""),
|
||
"type": file_info.get("file_type", ""),
|
||
"status": file_info.get("status", "done"),
|
||
"created_at": created_at,
|
||
"is_folder": file_info.get("is_folder", False),
|
||
"parent_id": file_info.get("parent_id", None),
|
||
}
|
||
|
||
# 按创建时间倒序排序文件列表
|
||
sorted_files = dict(
|
||
sorted(
|
||
db_files.items(),
|
||
key=lambda item: item[1].get("created_at") or "",
|
||
reverse=True,
|
||
)
|
||
)
|
||
|
||
db_dict["files"] = sorted_files
|
||
|
||
db_dict["status"] = "已连接"
|
||
databases.append(db_dict)
|
||
|
||
return {"databases": databases}
|
||
|
||
@classmethod
|
||
def _add_to_processing_queue(cls, file_id: str) -> None:
|
||
"""
|
||
将文件添加到处理队列
|
||
|
||
Args:
|
||
file_id: 文件ID
|
||
"""
|
||
with cls._processing_lock:
|
||
cls._processing_files.add(file_id)
|
||
logger.debug(f"Added file {file_id} to processing queue")
|
||
|
||
@classmethod
|
||
def _remove_from_processing_queue(cls, file_id: str) -> None:
|
||
"""
|
||
从处理队列中移除文件
|
||
|
||
Args:
|
||
file_id: 文件ID
|
||
"""
|
||
with cls._processing_lock:
|
||
cls._processing_files.discard(file_id)
|
||
logger.debug(f"Removed file {file_id} from processing queue")
|
||
|
||
@classmethod
|
||
def _is_file_in_processing_queue(cls, file_id: str) -> bool:
|
||
"""
|
||
检查文件是否在处理队列中
|
||
|
||
Args:
|
||
file_id: 文件ID
|
||
|
||
Returns:
|
||
bool: 文件是否在处理队列中
|
||
"""
|
||
with cls._processing_lock:
|
||
return file_id in cls._processing_files
|
||
|
||
def _check_and_fix_processing_status(self, kb_id: str) -> None:
|
||
"""
|
||
检查并修复异常的处理中状态
|
||
如果文件状态为处理中但实际不在处理队列中,则修改为相应的错误状态
|
||
|
||
Args:
|
||
kb_id: 数据库ID
|
||
"""
|
||
try:
|
||
status_changed = False
|
||
|
||
# 定义需要检查的中间状态及其对应的错误状态
|
||
intermediate_states = {
|
||
FileStatus.PARSING: FileStatus.ERROR_PARSING,
|
||
FileStatus.INDEXING: FileStatus.ERROR_INDEXING,
|
||
}
|
||
|
||
# 检查该数据库下所有中间状态的文件
|
||
for file_id, file_info in self.files_meta.items():
|
||
if file_info.get("kb_id") == kb_id:
|
||
current_status = file_info.get("status")
|
||
|
||
if current_status in intermediate_states:
|
||
# 检查文件是否真的在处理队列中
|
||
if not self._is_file_in_processing_queue(file_id):
|
||
error_status = intermediate_states[current_status]
|
||
logger.warning(
|
||
f"File {file_id} has {current_status} status but is not in processing queue, "
|
||
f"marking as {error_status}"
|
||
)
|
||
self.files_meta[file_id]["status"] = error_status
|
||
self.files_meta[file_id]["error"] = (
|
||
f"{current_status.capitalize()} interrupted - process not found in queue"
|
||
)
|
||
self.files_meta[file_id]["updated_at"] = utc_isoformat()
|
||
status_changed = True
|
||
|
||
# 如果有状态变更,保存元数据
|
||
if status_changed:
|
||
logger.info(f"Fixed interrupted processing status for database {kb_id}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error checking processing status for database {kb_id}: {e}")
|
||
|
||
async def delete_folder(self, kb_id: str, folder_id: str) -> None:
|
||
"""
|
||
Recursively delete a folder and its content.
|
||
|
||
Args:
|
||
kb_id: Database ID
|
||
folder_id: Folder ID to delete
|
||
"""
|
||
# Find all children
|
||
children = [
|
||
fid
|
||
for fid, meta in self.files_meta.items()
|
||
if meta.get("kb_id") == kb_id and meta.get("parent_id") == folder_id
|
||
]
|
||
|
||
for child_id in children:
|
||
child_meta = self.files_meta.get(child_id)
|
||
if child_meta and child_meta.get("is_folder"):
|
||
await self.delete_folder(kb_id, child_id)
|
||
else:
|
||
await self.delete_file(kb_id, child_id)
|
||
|
||
# Delete the folder itself
|
||
# We call delete_file which should handle the actual removal.
|
||
# Implementations should ensure they handle folder deletion gracefully (e.g. skip vector deletion)
|
||
await self.delete_file(kb_id, folder_id)
|
||
|
||
async def move_file(self, kb_id: str, file_id: str, new_parent_id: str | None) -> dict:
|
||
"""
|
||
Move a file or folder to a new parent folder.
|
||
|
||
Args:
|
||
kb_id: Database ID
|
||
file_id: File/Folder ID to move
|
||
new_parent_id: New parent folder ID (None for root)
|
||
|
||
Returns:
|
||
dict: Updated metadata
|
||
"""
|
||
if file_id not in self.files_meta:
|
||
raise ValueError(f"File {file_id} not found")
|
||
|
||
meta = self.files_meta[file_id]
|
||
if meta.get("kb_id") != kb_id:
|
||
raise ValueError(f"File {file_id} does not belong to database {kb_id}")
|
||
|
||
# Basic cycle detection for folders
|
||
if meta.get("is_folder") and new_parent_id:
|
||
# Check if new_parent_id is a child of file_id (or is file_id itself)
|
||
if new_parent_id == file_id:
|
||
raise ValueError("Cannot move a folder into itself")
|
||
|
||
# Walk up the tree from new_parent_id
|
||
current = new_parent_id
|
||
while current:
|
||
parent_meta = self.files_meta.get(current)
|
||
if not parent_meta:
|
||
break # Should not happen if integrity is maintained
|
||
if current == file_id:
|
||
raise ValueError("Cannot move a folder into its own subfolder")
|
||
current = parent_meta.get("parent_id")
|
||
|
||
meta["parent_id"] = new_parent_id
|
||
await self._persist_file(file_id)
|
||
return meta
|
||
|
||
@abstractmethod
|
||
async def delete_file(self, kb_id: str, file_id: str) -> None:
|
||
"""
|
||
删除文件
|
||
|
||
Args:
|
||
kb_id: 数据库ID
|
||
file_id: 文件ID
|
||
"""
|
||
pass
|
||
|
||
@abstractmethod
|
||
async def get_file_basic_info(self, kb_id: str, file_id: str) -> dict:
|
||
"""
|
||
获取文件基本信息(仅元数据)
|
||
|
||
Args:
|
||
kb_id: 数据库ID
|
||
file_id: 文件ID
|
||
|
||
Returns:
|
||
dict: 包含文件基本信息的字典
|
||
"""
|
||
pass
|
||
|
||
@abstractmethod
|
||
async def get_file_content(self, kb_id: str, file_id: str) -> dict:
|
||
"""
|
||
获取文件内容信息(chunks和lines)
|
||
|
||
Args:
|
||
kb_id: 数据库ID
|
||
file_id: 文件ID
|
||
|
||
Returns:
|
||
dict: 包含文件内容信息的字典
|
||
"""
|
||
pass
|
||
|
||
@abstractmethod
|
||
async def get_file_info(self, kb_id: str, file_id: str) -> dict:
|
||
"""
|
||
获取文件完整信息(基本信息+内容信息)
|
||
|
||
Args:
|
||
kb_id: 数据库ID
|
||
file_id: 文件ID
|
||
|
||
Returns:
|
||
dict: 包含文件信息和chunks的字典
|
||
"""
|
||
pass
|
||
|
||
def update_database(
|
||
self,
|
||
kb_id: str,
|
||
name: str,
|
||
description: str,
|
||
llm_model_spec: str | None = None,
|
||
update_llm_model_spec: bool = False,
|
||
) -> dict:
|
||
"""
|
||
更新数据库
|
||
|
||
Args:
|
||
kb_id: 数据库ID
|
||
name: 新名称
|
||
description: 新描述
|
||
llm_model_spec: LLM 模型 spec(可选)
|
||
|
||
Returns:
|
||
更新后的数据库信息
|
||
"""
|
||
if kb_id not in self.databases_meta:
|
||
raise ValueError(f"数据库 {kb_id} 不存在")
|
||
|
||
self.databases_meta[kb_id]["name"] = name
|
||
self.databases_meta[kb_id]["description"] = description
|
||
if update_llm_model_spec:
|
||
self.databases_meta[kb_id]["llm_model_spec"] = llm_model_spec
|
||
|
||
return self.get_database_info(kb_id)
|
||
|
||
def get_retrievers(self) -> dict[str, dict]:
|
||
"""
|
||
获取所有检索器
|
||
|
||
Returns:
|
||
检索器字典
|
||
"""
|
||
retrievers = {}
|
||
for kb_id, meta in self.databases_meta.items():
|
||
|
||
def make_retriever(kb_id):
|
||
async def retriever(query_text, **kwargs):
|
||
results = await self.aquery(query_text, kb_id, agent_call=True, **kwargs)
|
||
return self.build_search_output(kb_id, results)
|
||
|
||
return retriever
|
||
|
||
retrievers[kb_id] = {
|
||
"name": meta["name"],
|
||
"description": meta["description"],
|
||
"retriever": make_retriever(kb_id),
|
||
"metadata": meta,
|
||
}
|
||
return retrievers
|
||
|
||
async def _load_metadata(self) -> None:
|
||
from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository
|
||
from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository
|
||
|
||
kb_repo = KnowledgeBaseRepository()
|
||
file_repo = KnowledgeFileRepository()
|
||
|
||
databases = [kb for kb in await kb_repo.get_all() if kb.kb_type == self.kb_type]
|
||
self.databases_meta = {
|
||
kb.kb_id: {
|
||
"name": kb.name,
|
||
"description": kb.description,
|
||
"kb_type": kb.kb_type,
|
||
"embedding_model_spec": kb.embedding_model_spec,
|
||
"llm_model_spec": kb.llm_model_spec,
|
||
"query_params": kb.query_params or self._get_default_query_params(kb.kb_id),
|
||
"metadata": self.normalize_additional_params(kb.additional_params),
|
||
"created_at": utc_isoformat(kb.created_at) if kb.created_at else utc_isoformat(),
|
||
}
|
||
for kb in databases
|
||
}
|
||
|
||
self.files_meta = {}
|
||
for kb in databases:
|
||
kb_additional_params = self.databases_meta.get(kb.kb_id, {}).get("metadata") or {}
|
||
for record in await file_repo.list_by_kb_id(kb.kb_id):
|
||
self.files_meta[record.file_id] = {
|
||
"file_id": record.file_id,
|
||
"kb_id": record.kb_id,
|
||
"parent_id": record.parent_id,
|
||
"filename": record.filename,
|
||
"file_type": record.file_type,
|
||
"path": record.path,
|
||
"markdown_file": record.markdown_file,
|
||
"status": record.status,
|
||
"content_hash": record.content_hash,
|
||
"size": record.file_size,
|
||
"content_type": record.content_type,
|
||
"processing_params": sanitize_processing_params(
|
||
resolve_processing_params(
|
||
kb_additional_params=kb_additional_params,
|
||
file_processing_params=record.processing_params,
|
||
)
|
||
),
|
||
"is_folder": record.is_folder,
|
||
"error": record.error_message,
|
||
"created_by": record.created_by,
|
||
"updated_by": record.updated_by,
|
||
"created_at": utc_isoformat(record.created_at) if record.created_at else None,
|
||
"updated_at": utc_isoformat(record.updated_at) if record.updated_at else None,
|
||
"original_filename": record.original_filename,
|
||
"minio_url": record.minio_url,
|
||
}
|
||
|
||
self.benchmarks_meta = {}
|
||
|
||
logger.info(f"Loaded {self.kb_type} metadata from database for {len(self.databases_meta)} databases")
|
||
await self._fill_missing_file_sizes()
|
||
|
||
async def _fill_missing_file_sizes(self) -> None:
|
||
"""为缺少 size 的已有文件从 MinIO 补全大小信息"""
|
||
from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url
|
||
from yuxi.storage.minio import get_minio_client
|
||
|
||
files_to_update: list[str] = []
|
||
for file_id, meta in self.files_meta.items():
|
||
if meta.get("is_folder"):
|
||
continue
|
||
if meta.get("size") is not None:
|
||
continue
|
||
file_path = meta.get("minio_url") or meta.get("path")
|
||
if not file_path or not is_minio_url(file_path):
|
||
continue
|
||
files_to_update.append(file_id)
|
||
|
||
if not files_to_update:
|
||
return
|
||
|
||
minio_client = get_minio_client()
|
||
|
||
async def _stat_file(file_id: str, file_path: str) -> tuple[str, int | None]:
|
||
bucket_name, obj_name = parse_minio_url(file_path)
|
||
try:
|
||
return file_id, await minio_client.astat_file(bucket_name, obj_name)
|
||
except Exception as exc:
|
||
logger.warning(f"Failed to fill size for {file_id}: {exc}")
|
||
return file_id, None
|
||
|
||
results = await asyncio.gather(
|
||
*(
|
||
_stat_file(fid, self.files_meta[fid].get("minio_url") or self.files_meta[fid].get("path"))
|
||
for fid in files_to_update
|
||
)
|
||
)
|
||
updated = 0
|
||
for file_id, file_size in results:
|
||
if file_size is not None:
|
||
self.files_meta[file_id]["size"] = file_size
|
||
updated += 1
|
||
|
||
if updated:
|
||
logger.info(f"Filled {updated}/{len(files_to_update)} missing file sizes from MinIO for {self.kb_type}")
|
||
for file_id, file_size in results:
|
||
if file_size is not None:
|
||
await self._persist_file(file_id)
|
||
|
||
async def _save_metadata(self) -> None:
|
||
from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository
|
||
from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository
|
||
|
||
kb_repo = KnowledgeBaseRepository()
|
||
file_repo = KnowledgeFileRepository()
|
||
|
||
self._normalize_metadata_state()
|
||
|
||
for kb_id, meta in self.databases_meta.items():
|
||
existing = await kb_repo.get_by_kb_id(kb_id)
|
||
payload = {
|
||
"kb_id": kb_id,
|
||
"name": meta.get("name") or kb_id,
|
||
"description": meta.get("description"),
|
||
"kb_type": meta.get("kb_type") or self.kb_type,
|
||
"embedding_model_spec": meta.get("embedding_model_spec"),
|
||
"llm_model_spec": meta.get("llm_model_spec"),
|
||
"query_params": meta.get("query_params"),
|
||
"additional_params": meta.get("metadata") or {},
|
||
}
|
||
if existing is None:
|
||
await kb_repo.create(payload)
|
||
|
||
for file_id, meta in self.files_meta.items():
|
||
kb_id = meta.get("kb_id")
|
||
if not kb_id:
|
||
continue
|
||
await file_repo.upsert(
|
||
file_id=file_id,
|
||
data={
|
||
"kb_id": kb_id,
|
||
"parent_id": meta.get("parent_id"),
|
||
"filename": meta.get("filename") or "",
|
||
"original_filename": meta.get("original_filename"),
|
||
"file_type": meta.get("file_type"),
|
||
"path": meta.get("path"),
|
||
"minio_url": meta.get("minio_url"),
|
||
"markdown_file": meta.get("markdown_file"),
|
||
"status": meta.get("status"),
|
||
"content_hash": meta.get("content_hash"),
|
||
"file_size": meta.get("size"),
|
||
"content_type": meta.get("content_type"),
|
||
"processing_params": sanitize_processing_params(meta.get("processing_params")),
|
||
"is_folder": meta.get("is_folder", False),
|
||
"error_message": meta.get("error"),
|
||
"created_by": str(meta.get("created_by")) if meta.get("created_by") else None,
|
||
"updated_by": str(meta.get("updated_by")) if meta.get("updated_by") else None,
|
||
},
|
||
)
|
||
|
||
async def _persist_file(self, file_id: str) -> None:
|
||
"""只保存单个文件到数据库,避免全量遍历"""
|
||
from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository
|
||
|
||
file_repo = KnowledgeFileRepository()
|
||
|
||
if file_id not in self.files_meta:
|
||
return
|
||
|
||
meta = self.files_meta[file_id]
|
||
kb_id = meta.get("kb_id")
|
||
if not kb_id:
|
||
return
|
||
|
||
await file_repo.upsert(
|
||
file_id=file_id,
|
||
data={
|
||
"kb_id": kb_id,
|
||
"parent_id": meta.get("parent_id"),
|
||
"filename": meta.get("filename") or "",
|
||
"original_filename": meta.get("original_filename"),
|
||
"file_type": meta.get("file_type"),
|
||
"path": meta.get("path"),
|
||
"minio_url": meta.get("minio_url"),
|
||
"markdown_file": meta.get("markdown_file"),
|
||
"status": meta.get("status"),
|
||
"content_hash": meta.get("content_hash"),
|
||
"file_size": meta.get("size"),
|
||
"content_type": meta.get("content_type"),
|
||
"processing_params": sanitize_processing_params(meta.get("processing_params")),
|
||
"is_folder": meta.get("is_folder", False),
|
||
"error_message": meta.get("error"),
|
||
"created_by": str(meta.get("created_by")) if meta.get("created_by") else None,
|
||
"updated_by": str(meta.get("updated_by")) if meta.get("updated_by") else None,
|
||
},
|
||
)
|
||
|
||
async def _persist_kb(self, kb_id: str) -> None:
|
||
"""只保存单个知识库到数据库,避免全量遍历"""
|
||
from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository
|
||
|
||
kb_repo = KnowledgeBaseRepository()
|
||
|
||
if kb_id not in self.databases_meta:
|
||
return
|
||
|
||
meta = self.databases_meta[kb_id]
|
||
existing = await kb_repo.get_by_kb_id(kb_id)
|
||
payload = {
|
||
"kb_id": kb_id,
|
||
"name": meta.get("name") or kb_id,
|
||
"description": meta.get("description"),
|
||
"kb_type": meta.get("kb_type") or self.kb_type,
|
||
"embedding_model_spec": meta.get("embedding_model_spec"),
|
||
"llm_model_spec": meta.get("llm_model_spec"),
|
||
"query_params": meta.get("query_params"),
|
||
"additional_params": meta.get("metadata") or {},
|
||
}
|
||
|
||
if existing is None:
|
||
await kb_repo.create(payload)
|
||
else:
|
||
await kb_repo.update(
|
||
kb_id,
|
||
{
|
||
"name": payload["name"],
|
||
"description": payload["description"],
|
||
"kb_type": payload["kb_type"],
|
||
"embedding_model_spec": payload["embedding_model_spec"],
|
||
"llm_model_spec": payload["llm_model_spec"],
|
||
"query_params": payload["query_params"],
|
||
"additional_params": payload["additional_params"],
|
||
},
|
||
)
|