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.services.file_preview import ( MAX_BINARY_PREVIEW_SIZE_BYTES, OfficePreviewConversionError, convert_office_to_pdf, detect_media_type, is_binary_preview_type, is_office_pdf_preview_file, render_preview_payload, render_preview_too_large_payload, ) 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" INDEXED_STATS_STATUSES = {FileStatus.INDEXED, "done"} def _should_repair_file_stats(file_meta: dict) -> bool: status = file_meta.get("status") return status is None or status in INDEXED_STATS_STATUSES 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 def __init__(self, work_dir: str): """ 初始化知识库 Args: work_dir: 工作目录 """ self.work_dir = work_dir self.databases_meta: dict[str, dict] = {} self.benchmarks_meta: dict[str, dict] = {} self._metadata_loaded = False # 标记元数据是否已加载 os.makedirs(work_dir, exist_ok=True) # 注意:不在 __init__ 中加载元数据,由 KnowledgeBaseManager 统一管理加载 def load_metadata( self, global_databases_meta: dict[str, dict], _unused_file_metadata: 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"), } del _unused_file_metadata # 文件元数据以 PostgreSQL 为准,不在 KnowledgeBase 实例中缓存。 # 过滤评估基准 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 _file_record_to_meta(self, record: Any) -> dict: kb_additional_params = self.databases_meta.get(record.kb_id, {}).get("metadata") or {} return { "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, "chunk_count": int(getattr(record, "chunk_count", 0) or 0), "token_count": int(getattr(record, "token_count", 0) or 0), "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, } @staticmethod def _file_meta_to_record_data(meta: dict) -> dict[str, Any]: return { "kb_id": meta.get("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"), "chunk_count": int(meta.get("chunk_count") or 0), "token_count": int(meta.get("token_count") or 0), "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 _load_file_meta(self, kb_id: str, file_id: str, *, refresh: bool = False) -> dict: del refresh from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository record = await KnowledgeFileRepository().get_by_file_id(file_id) if record is None or record.kb_id != kb_id: raise ValueError(f"File {file_id} not found") return self._file_record_to_meta(record) 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 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 await self._persist_file_meta(file_id, metadata) await self.refresh_database_stats(kb_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 """ # Validate current status - only allow parsing from these states allowed_statuses = { FileStatus.UPLOADED, FileStatus.ERROR_PARSING, "failed", # Legacy status } from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository file_repo = KnowledgeFileRepository() claim_data = {"status": FileStatus.PARSING, "error_message": None} if operator_id: claim_data["updated_by"] = operator_id claimed_record = await file_repo.update_fields_if_status( kb_id=kb_id, file_id=file_id, allowed_statuses=allowed_statuses, data=claim_data, ) if claimed_record is None: current_meta = await self._load_file_meta(kb_id, file_id) current_status = current_meta.get("status") raise ValueError( f"Cannot parse file with status '{current_status}'. " f"File must be in one of these states: {', '.join(allowed_statuses)}" ) file_meta = self._file_record_to_meta(claimed_record) file_path = file_meta.get("path") if not file_path: message = f"File {file_id} has no valid path in metadata" update_data = {"status": FileStatus.ERROR_PARSING, "error_message": message} if operator_id: update_data["updated_by"] = operator_id await file_repo.update_fields(file_id=file_id, kb_id=kb_id, data=update_data) raise ValueError(message) 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 file_meta["status"] = FileStatus.PARSED file_meta["markdown_file"] = markdown_file_path file_meta["error"] = None file_meta["updated_at"] = utc_isoformat() if operator_id: file_meta["updated_by"] = operator_id update_data = { "status": FileStatus.PARSED, "markdown_file": markdown_file_path, "error_message": None, } if operator_id: update_data["updated_by"] = operator_id await file_repo.update_fields(file_id=file_id, kb_id=kb_id, data=update_data) return file_meta except Exception as e: error_msg = str(e) logger.error(f"Failed to parse file {file_id}: {error_msg}") file_meta["status"] = FileStatus.ERROR_PARSING file_meta["error"] = error_msg file_meta["updated_at"] = utc_isoformat() if operator_id: file_meta["updated_by"] = operator_id update_data = {"status": FileStatus.ERROR_PARSING, "error_message": error_msg} if operator_id: update_data["updated_by"] = operator_id await file_repo.update_fields(file_id=file_id, kb_id=kb_id, data=update_data) raise async def update_file_params(self, kb_id: str, file_id: str, params: dict, operator_id: str | None = None) -> None: """Update file processing params""" # Skip if no params to update if not params: return file_meta = await self._load_file_meta(kb_id, file_id) current_params = file_meta.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, ) file_meta["processing_params"] = current_params file_meta["updated_at"] = utc_isoformat() if operator_id: file_meta["updated_by"] = operator_id logger.debug(f"[update_file_params] file_id={file_id}, updated_params={current_params}") from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository update_data = {"processing_params": sanitize_processing_params(current_params)} if operator_id: update_data["updated_by"] = operator_id record = await KnowledgeFileRepository().update_fields(file_id=file_id, kb_id=kb_id, data=update_data) if record is None: raise ValueError(f"File {file_id} not found") async def _mark_file_unparsed(self, kb_id: str, file_id: str, operator_id: str | None = None) -> None: from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository update_data = {"status": FileStatus.UPLOADED, "markdown_file": None, "error_message": None} if operator_id: update_data["updated_by"] = operator_id record = await KnowledgeFileRepository().update_fields(file_id=file_id, kb_id=kb_id, data=update_data) if record is None: raise ValueError(f"File {file_id} not found") 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") async def _get_file_meta(self, kb_id: str, file_id: str) -> dict: return await self._load_file_meta(kb_id, file_id) @staticmethod def _original_file_path(file_meta: dict) -> str | None: return file_meta.get("minio_url") or file_meta.get("path") def _knowledge_file_entry(self, kb_id: str, file_id: str, file_meta: dict) -> dict: is_dir = bool(file_meta.get("is_folder")) original_path = self._original_file_path(file_meta) 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"), "has_original_file": bool(original_path), "has_parsed_markdown": bool(file_meta.get("markdown_file")), } 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()), ) async def _list_knowledge_children( self, kb_id: str, parent_id: str | None, *, recursive: bool, files_only: bool, ) -> list[dict]: from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository children = [] for record in await KnowledgeFileRepository().list_children(kb_id=kb_id, parent_id=parent_id): meta = self._file_record_to_meta(record) children.append((record.file_id, meta)) 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( await 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 = await self._get_file_meta(kb_id, parent_id) if not parent_meta.get("is_folder"): raise ValueError("Parent is not a folder") return { "entries": await self._list_knowledge_children( kb_id, parent_id, recursive=recursive, files_only=files_only, ), "readonly": True, } @staticmethod def _office_pdf_preview_path(kb_id: str, file_id: str) -> str: return f"{kb_id}/preview/{file_id}.pdf" async def _ensure_office_pdf_preview(self, kb_id: str, file_id: str, file_meta: dict) -> str: from yuxi.storage.minio import get_minio_client filename = file_meta.get("filename") or file_meta.get("original_filename") or file_id if not is_office_pdf_preview_file(filename): raise ValueError("当前文件类型不支持 PDF 预览") minio_client = get_minio_client() bucket_name = minio_client.KB_BUCKETS["parsed"] object_name = self._office_pdf_preview_path(kb_id, file_id) if await minio_client.astat_file(bucket_name, object_name) is not None: return f"minio://{bucket_name}/{object_name}" original_path = self._original_file_path(file_meta) if not original_path: raise ValueError("文件没有可转换的原始内容") raw_content = await self._read_minio_bytes(original_path) try: pdf_content = await convert_office_to_pdf(filename, raw_content) except OfficePreviewConversionError as exc: raise ValueError(str(exc)) from exc await minio_client.aupload_file( bucket_name=bucket_name, object_name=object_name, data=pdf_content, content_type="application/pdf", ) return f"minio://{bucket_name}/{object_name}" async def _get_minio_file_size(self, file_path: str) -> int | None: 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): return None bucket_name, object_name = parse_minio_url(file_path) return await get_minio_client().astat_file(bucket_name, object_name) async def read_file_preview(self, kb_id: str, file_id: str) -> dict: file_meta = await self._get_file_meta(kb_id, file_id) if file_meta.get("is_folder"): raise ValueError("Cannot preview a folder") 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, "readonly": True, } original_path = self._original_file_path(file_meta) if not original_path: return { **response, "content": None, "preview_type": "unsupported", "supported": False, "message": "文件没有可预览的原始内容", } file_size = file_meta.get("size") if file_size is None: file_size = await self._get_minio_file_size(original_path) if file_size is not None and int(file_size) > MAX_BINARY_PREVIEW_SIZE_BYTES: return {**response, **render_preview_too_large_payload()} if is_office_pdf_preview_file(filename): preview_path = await self._ensure_office_pdf_preview(kb_id, file_id, file_meta) stem = filename.rsplit(".", 1)[0] or file_id return { **response, "content": await self._read_minio_bytes(preview_path), "filename": f"{stem}.pdf", "media_type": "application/pdf", "preview_type": "pdf", "supported": True, "message": None, "binary": True, } raw_content = await self._read_minio_bytes(original_path) if len(raw_content) > MAX_BINARY_PREVIEW_SIZE_BYTES: return {**response, **render_preview_too_large_payload()} payload = render_preview_payload(filename, raw_content) if is_binary_preview_type(payload["preview_type"]) and payload["supported"]: return { **response, "content": raw_content, "media_type": detect_media_type(filename, raw_content), "preview_type": payload["preview_type"], "supported": True, "message": None, "binary": True, } return {**response, **payload} async def get_file_download(self, kb_id: str, file_id: str, variant: str = "original") -> dict: file_meta = await 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 内容""" try: file_meta = await self._load_file_meta(kb_id, file_id) except ValueError as exc: raise Exception(f"文件不存在: {file_id}") from exc 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: try: file_meta = await self._load_file_meta(kb_id, file_id) except ValueError as exc: raise Exception(f"文件不存在: {file_id}") from exc 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, record_fields: dict[str, Any] | None = None, **kwargs, ) -> dict: """ 创建数据库 Args: database_name: 数据库名称 description: 数据库描述 embedding_model_spec: 嵌入模型 spec llm_model_spec: LLM 模型 spec record_fields: 首次持久化知识库记录时由上层传入的受控业务字段 **kwargs: 其他配置参数 Returns: 数据库信息字典 """ kwargs = self.normalize_additional_params(kwargs) kwargs["stats"] = {"file_count": 0, "chunk_count": 0, "token_count": 0} 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, record_fields=record_fields) # 创建工作目录 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.repositories.knowledge_file_repository import KnowledgeFileRepository from yuxi.storage.minio import get_minio_client minio_client = get_minio_client() file_repo = KnowledgeFileRepository() # 1. 删除文件元数据中记录的 MinIO 文件 after_file_id = None while True: records = await file_repo.list_by_kb_id_after(kb_id, after_file_id=after_file_id, limit=500) if not records: break after_file_id = records[-1].file_id for record in records: file_id = record.file_id file_path = record.minio_url or record.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) # 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] await file_repo.delete_by_kb_id(kb_id) kb_repo = KnowledgeBaseRepository() await kb_repo.delete(kb_id) # 删除工作目录 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 if parent_id: parent_meta = await self._load_file_meta(kb_id, parent_id) if not parent_meta.get("is_folder"): raise ValueError("Parent is not a folder") folder_id = f"folder-{uuid.uuid4()}" folder_meta = { "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_meta(folder_id, folder_meta) return folder_meta @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 _build_database_stats(self, kb_id: str) -> dict[str, int]: del kb_id return self._normalize_database_stats(None) @staticmethod def _normalize_database_stats(stats: dict | None) -> dict[str, int]: normalized = { "file_count": 0, "folder_count": 0, "row_count": 0, "total_size": 0, "chunk_count": 0, "token_count": 0, "pending_parse_count": 0, "pending_index_count": 0, "processing_count": 0, } if not isinstance(stats, dict): return normalized for key in normalized: try: normalized[key] = max(int(stats.get(key) or 0), 0) except (TypeError, ValueError): normalized[key] = 0 return normalized def _get_database_stats(self, kb_id: str) -> dict[str, int]: metadata = self.databases_meta.get(kb_id, {}).get("metadata") or {} stats = metadata.get("stats") if isinstance(metadata, dict) else None if isinstance(stats, dict): return self._normalize_database_stats(stats) return self._build_database_stats(kb_id) def _set_database_stats(self, kb_id: str, stats: dict[str, int]) -> None: if kb_id not in self.databases_meta: raise ValueError(f"Database {kb_id} not found") metadata = self.databases_meta[kb_id].setdefault("metadata", {}) metadata["stats"] = self._normalize_database_stats(stats) async def refresh_database_stats(self, kb_id: str) -> dict[str, int]: from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository stats = await KnowledgeFileRepository().get_kb_file_stats(kb_id) self._set_database_stats(kb_id, stats) await self._persist_kb(kb_id) return stats async def repair_missing_file_stats(self, kb_id: str) -> dict: if kb_id not in self.databases_meta: raise ValueError(f"Database {kb_id} not found") from yuxi.knowledge.chunking.ragflow_like.nlp import count_tokens from yuxi.repositories.knowledge_chunk_repository import KnowledgeChunkRepository from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository chunk_repo = KnowledgeChunkRepository() file_repo = KnowledgeFileRepository() after_file_id = None scanned_files = 0 scanned_indexed_files = 0 skipped_file_count = 0 scanned_token_files = 0 updated_files = 0 updated_chunk_files = 0 updated_token_files = 0 updated_size_files = 0 while True: records = await file_repo.list_by_kb_id_after( kb_id, after_file_id=after_file_id, limit=500, files_only=True, ) if not records: break after_file_id = records[-1].file_id indexed_records = [record for record in records if _should_repair_file_stats({"status": record.status})] indexed_file_ids = [record.file_id for record in indexed_records] indexed_file_id_set = set(indexed_file_ids) chunk_counts = await chunk_repo.count_by_file_ids(indexed_file_ids) token_file_ids = [record.file_id for record in indexed_records if int(record.token_count or 0) <= 0] token_counts = {file_id: 0 for file_id in token_file_ids} for chunk in await chunk_repo.list_by_file_ids(token_file_ids): token_counts[chunk.file_id] = token_counts.get(chunk.file_id, 0) + count_tokens(chunk.content or "") size_updates = await self._fill_missing_file_sizes_for_records(records) scanned_files += len(records) scanned_indexed_files += len(indexed_file_ids) skipped_file_count += len(records) - len(indexed_file_ids) scanned_token_files += len(token_file_ids) for record in records: file_id = record.file_id update_data: dict[str, Any] = {} if file_id not in indexed_file_id_set: if int(record.chunk_count or 0) != 0: update_data["chunk_count"] = 0 updated_chunk_files += 1 if int(record.token_count or 0) != 0: update_data["token_count"] = 0 updated_token_files += 1 else: next_chunk_count = int(chunk_counts.get(file_id, 0)) if int(record.chunk_count or 0) != next_chunk_count: update_data["chunk_count"] = next_chunk_count updated_chunk_files += 1 if file_id in token_counts: next_token_count = int(token_counts[file_id]) if record.token_count is None or int(record.token_count or 0) != next_token_count: update_data["token_count"] = next_token_count updated_token_files += 1 if file_id in size_updates: update_data["file_size"] = size_updates[file_id] updated_size_files += 1 if update_data: updated_files += 1 await file_repo.update_fields(file_id=file_id, kb_id=kb_id, data=update_data) stats = await self.refresh_database_stats(kb_id) return { "status": "success", "stats": stats, "scanned_files": scanned_files, "scanned_indexed_files": scanned_indexed_files, "skipped_unindexed_files": skipped_file_count, "scanned_token_files": scanned_token_files, "updated_files": updated_files, "updated_chunk_files": updated_chunk_files, "updated_token_files": updated_token_files, "updated_size_files": updated_size_files, } 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 meta["stats"] = self._get_database_stats(kb_id) meta["row_count"] = meta["stats"].get("row_count") or meta["stats"].get("file_count") or 0 if include_files: meta["files"] = {} meta["files_truncated"] = True 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(): db_dict = meta.copy() db_dict["kb_id"] = kb_id db_dict["stats"] = self._get_database_stats(kb_id) db_dict["row_count"] = db_dict["stats"].get("row_count") or db_dict["stats"].get("file_count") or 0 if include_files: db_dict["files"] = {} db_dict["files_truncated"] = True db_dict["status"] = "已连接" databases.append(db_dict) return {"databases": databases} 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 """ from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository children = await KnowledgeFileRepository().list_children(kb_id=kb_id, parent_id=folder_id) for child in children: child_id = child.file_id if child.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 """ meta = await self._load_file_meta(kb_id, file_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 = await self._load_file_meta(kb_id, current) if current == new_parent_id and not parent_meta.get("is_folder"): raise ValueError("Parent is not a folder") if current == file_id: raise ValueError("Cannot move a folder into its own subfolder") current = parent_meta.get("parent_id") elif new_parent_id: parent_meta = await self._load_file_meta(kb_id, new_parent_id) if not parent_meta.get("is_folder"): raise ValueError("Parent is not a folder") meta["parent_id"] = new_parent_id await self._persist_file_meta(file_id, meta) 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 kb_repo = KnowledgeBaseRepository() 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 } # 文件元数据量可能达到几十万级,启动阶段只加载 KB 级配置。 # 单文件操作按需查询 PostgreSQL,并在流程内通过局部变量传递。 self.benchmarks_meta = {} self._normalize_metadata_state() self._metadata_loaded = True logger.info(f"Loaded {self.kb_type} metadata from database for {len(self.databases_meta)} databases") async def _fill_missing_file_sizes_for_records(self, records: list[Any]) -> dict[str, int]: """为显式修复任务中的缺失 size 文件从 MinIO 补全大小信息。""" from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url from yuxi.storage.minio import get_minio_client candidates: list[tuple[str, str]] = [] for record in records: if record.is_folder or record.file_size is not None: continue file_path = record.minio_url or record.path if not file_path or not is_minio_url(file_path): continue candidates.append((record.file_id, file_path)) if not candidates: return {} minio_client = get_minio_client() semaphore = asyncio.Semaphore(20) async def _stat_file(file_id: str, file_path: str) -> tuple[str, int | None]: bucket_name, obj_name = parse_minio_url(file_path) try: async with semaphore: 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 updates: dict[str, int] = {} for offset in range(0, len(candidates), 100): batch = candidates[offset : offset + 100] for file_id, file_size in await asyncio.gather( *(_stat_file(file_id, file_path) for file_id, file_path in batch) ): if file_size is not None: updates[file_id] = file_size if updates: logger.info(f"Filled {len(updates)}/{len(candidates)} missing file sizes from MinIO for {self.kb_type}") return updates async def _save_metadata(self) -> None: from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository kb_repo = KnowledgeBaseRepository() 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) async def _persist_file_meta(self, file_id: str, meta: dict) -> None: """Persist one file metadata record without storing it on the KB instance.""" from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository data = self._file_meta_to_record_data(meta) if not data.get("kb_id"): return await KnowledgeFileRepository().upsert(file_id=file_id, data=data) async def _persist_kb(self, kb_id: str, record_fields: dict[str, Any] | None = None) -> 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 record_fields: allowed_fields = {"share_config", "created_by"} payload.update({key: value for key, value in record_fields.items() if key in allowed_fields}) if existing is None: await kb_repo.create(payload) else: update_data = { "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"], } if record_fields: update_data.update({key: payload[key] for key in allowed_fields if key in payload}) await kb_repo.update(kb_id, update_data)