ForcePilot/backend/package/yuxi/knowledge/base.py
Wenjie Zhang 99c590d003 refactor: 知识库系统重构 - 统一命名、优化图检索与向量存储逻辑
- base/manager: db_id → kb_id 命名统一,重构元数据加载与文件记录逻辑
- implementations: dify/milvus/notion/read_only 适配新命名与接口
- graphs: milvus_graph_service/vector_store/graph_utils 优化图构建与检索逻辑
- eval: benchmark_generation/evaluator/service 适配新命名并优化评估流程
- utils/kb_utils: 对应更新工具函数
2026-05-26 17:40:17 +08:00

1665 lines
62 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.plugins.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"],
},
)