diff --git a/api/main.py b/api/main.py index 6a7fd38..ec233a5 100644 --- a/api/main.py +++ b/api/main.py @@ -2,9 +2,6 @@ FastAPI main application """ import warnings -# Suppress pkg_resources deprecation warning from debugpy extension -# This warning is harmless and comes from VS Code/Cursor debugpy extension -# It appears when debugpy is loaded, which happens during debugging warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*", category=UserWarning) from contextlib import asynccontextmanager @@ -17,7 +14,8 @@ from pydantic import BaseModel, Field from typing import Optional, List, Dict, Any from loguru import logger from config import settings -from rag import VectorStoreManager, RAGEngine, FileParser, DocumentProcessor +from db_utils import get_db_connection, init_session_db +from rag import VectorStoreManager, RAGEngine, FileParser from sync_service import SyncServiceManager import requests from datetime import datetime @@ -35,6 +33,7 @@ import re import markdown2 from pathlib import Path import hashlib +from sync.base_sync import BaseSync # Global instances @@ -42,14 +41,13 @@ vector_store_manager: Optional[VectorStoreManager] = None rag_engine: Optional[RAGEngine] = None sync_manager: Optional[SyncServiceManager] = None file_parser: Optional[FileParser] = None -document_processor: Optional[DocumentProcessor] = None auto_sync_task = None # Keep reference to prevent garbage collection @asynccontextmanager async def lifespan(app: FastAPI): """Lifespan context manager for startup and shutdown events""" - global vector_store_manager, rag_engine, sync_manager, file_parser, document_processor, auto_sync_task + global vector_store_manager, rag_engine, sync_manager, file_parser, auto_sync_task # Startup try: @@ -79,13 +77,6 @@ async def lifespan(app: FastAPI): logger.error(f"Failed to initialize FileParser: {e}") raise - # Initialize document processor - try: - document_processor = DocumentProcessor() - logger.info("✓ DocumentProcessor initialized") - except Exception as e: - logger.error(f"Failed to initialize DocumentProcessor: {e}") - raise logger.info("✓ Core RAG services initialized") @@ -368,36 +359,7 @@ def hash_password(password: str) -> str: return hashlib.sha256(password.encode()).hexdigest() -def init_session_db(): - conn = sqlite3.connect(DB_PATH) - try: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - username TEXT UNIQUE, - password TEXT, - create_time TEXT - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - user_login TEXT, - title TEXT, - data TEXT, - update_time TEXT - ) - """ - ) - # Improve concurrency for writes - conn.execute("PRAGMA journal_mode=WAL;") - conn.execute("PRAGMA synchronous=NORMAL;") - conn.commit() - finally: - conn.close() + def load_sessions(): @@ -1049,41 +1011,23 @@ async def manual_sync(request: SyncRequest): try: if request.source_name: - # Sync specific data source - if hasattr(sync_manager, 'get_sync_service'): # Check if it's a SyncServiceManager - specific_sync_service = sync_manager.get_sync_service(request.source_name) - if not specific_sync_service: - raise HTTPException(status_code=404, detail=f"Sync service not found for source: {request.source_name}") - - if request.full_sync: - await specific_sync_service.sync_all(force=request.force, is_manual=True) - message = f"Full sync completed for {request.source_name}" + (" (forced re-processing)" if request.force else "") - else: - await specific_sync_service.sync_incremental(is_manual=True) - message = f"Incremental sync completed for {request.source_name}" + specific_sync_service = sync_manager.get_sync_service(request.source_name) + if not specific_sync_service: + raise HTTPException(status_code=404, detail=f"Sync service not found for source: {request.source_name}") + + if request.full_sync: + await specific_sync_service.sync_all(force=True, is_manual=True) + message = f"Full sync completed for {request.source_name}" + (" (forced re-processing)" if request.force else "") else: - # Old SyncService that handles all sources - if request.full_sync: - await sync_manager.sync_all(force=request.force) - message = "Full sync completed for all sources" + (" (forced re-processing)" if request.force else "") - else: - await sync_manager.sync_incremental() - message = "Incremental sync completed for all sources" + await specific_sync_service.sync_incremental(is_manual=True) + message = f"Incremental sync completed for {request.source_name}" + else: # Sync all data sources - if hasattr(sync_manager, 'start_all_sync_services'): - # This is a SyncServiceManager, it starts auto sync services which do initial sync - await sync_manager.start_all_sync_services() - message = "Started synchronization for all data sources" - else: - # Old SyncService - if request.full_sync: - await sync_manager.sync_all(force=request.force) - message = "Full sync completed for all sources" + (" (forced re-processing)" if request.force else "") - else: - await sync_manager.sync_incremental() - message = "Incremental sync completed for all sources" - + # This is a SyncServiceManager, it starts auto sync services which do initial sync + await sync_manager.start_all_sync_services() + message = "Started synchronization for all data sources" + return {"status": "success", "message": message} except Exception as e: logger.error(f"Error during sync: {e}") @@ -1153,7 +1097,7 @@ async def upload_document( HTTPException: 413 if file size exceeds 10MB HTTPException: 400 if document parsing fails """ - if file_parser is None or vector_store_manager is None or document_processor is None: + if file_parser is None or vector_store_manager is None: raise HTTPException(status_code=503, detail="Services not initialized") try: @@ -1217,7 +1161,7 @@ async def upload_document( # Chunk documents if needed logger.info(f"Chunking {len(documents)} document(s)...") - chunked_documents = document_processor.chunk_documents(documents) + chunked_documents = BaseSync.chunk_documents(documents) logger.info(f"Chunked into {len(chunked_documents)} chunk(s)") # Add to vector store (this may take time for large documents due to embedding generation) @@ -1401,20 +1345,10 @@ async def create_config(config: Dict[str, Any]): config["database"].lower(), config["table_name"].lower() ]) - elif config_type == "local_folder": - # 本地文件夹配置需要:文件夹路径 - if not config.get("folder_path"): - raise HTTPException(status_code=400, detail="本地文件夹配置必须包含文件夹路径") - # 替换路径中的特殊字符为下划线 - folder_path = config["folder_path"].lower().replace("/", "_").replace(":", "_").replace("\\", "_") - # 去除开头的下划线 - if folder_path.startswith("_"): - folder_path = folder_path[1:] - unique_id_parts.append(folder_path) - elif config_type == "remote_folder": - # 远程文件夹配置需要:主机、文件夹路径 + elif config_type == "folder": + # 文件夹配置需要:主机、文件夹路径 if not config.get("host") or not config.get("folder_path"): - raise HTTPException(status_code=400, detail="远程文件夹配置必须包含主机和文件夹路径") + raise HTTPException(status_code=400, detail="文件夹配置必须包含主机和文件夹路径") # 替换路径中的特殊字符为下划线 folder_path = config["folder_path"].lower().replace("/", "_").replace(":", "_").replace("\\", "_") # 去除开头的下划线 @@ -1453,20 +1387,13 @@ async def create_config(config: Dict[str, Any]): status_code=409, detail=f"已存在相同源的数据库配置。如需调整,请点击配置列表中的配置并修改配置内容。" ) - elif config_type == 'local_folder': - # For local folder configs, same source means same folder path - if existing_config_data.get('folder_path') == config.get('folder_path'): - raise HTTPException( - status_code=409, - detail=f"已存在相同路径的本地文件夹配置。如需调整,请点击配置列表中的配置并修改配置内容。" - ) - elif config_type == 'remote_folder': - # For remote folder configs, same source means same host and folder path + elif config_type == 'folder': + # For folder configs, same source means same host and folder path if (existing_config_data.get('host') == config.get('host') and existing_config_data.get('folder_path') == config.get('folder_path')): raise HTTPException( status_code=409, - detail=f"已存在相同服务器和路径的远程文件夹配置。如需调整,请点击配置列表中的配置并修改配置内容。" + detail=f"已存在相同服务器和路径的文件夹配置。如需调整,请点击配置列表中的配置并修改配置内容。" ) except sqlite3.OperationalError as e: # 表不存在的情况,会在后面创建表 @@ -1498,7 +1425,7 @@ async def create_config(config: Dict[str, Any]): global sync_manager if sync_manager is not None: # Create appropriate data source config object - from config import BaseDataSourceConfig, DatabaseDataSourceConfig, LocalFolderDataSourceConfig, RemoteFolderDataSourceConfig + from config import BaseDataSourceConfig, DatabaseDataSourceConfig, FolderDataSourceConfig if config_type == "database": source_config = DatabaseDataSourceConfig( @@ -1523,19 +1450,8 @@ async def create_config(config: Dict[str, Any]): scp_username=config.get("scp_username"), scp_password=config.get("scp_password") ) - elif config_type == "local_folder": - source_config = LocalFolderDataSourceConfig( - name=config_id, - folder_path=config.get("folder_path"), - host=config.get("host"), - port=config.get("port"), - username=config.get("username"), - password=config.get("password"), - recursive=config.get("recursive", True), - ignore_patterns=config.get("ignore_patterns") - ) - elif config_type == "remote_folder": - source_config = RemoteFolderDataSourceConfig( + elif config_type == "folder": + source_config = FolderDataSourceConfig( name=config_id, folder_path=config.get("folder_path"), host=config.get("host"), @@ -1570,36 +1486,7 @@ async def create_config(config: Dict[str, Any]): conn.close() -def get_db_connection(): - """ - Get a SQLite database connection with data_sources table initialized - - Returns: - tuple: (connection, cursor) - """ - DATA_DIR = Path(__file__).parent.parent / "data" - DATA_DIR.mkdir(parents=True, exist_ok=True) - DB_PATH = DATA_DIR / "sessions.db" - - conn = sqlite3.connect(DB_PATH) - cursor = conn.cursor() - - # 检查并创建data_sources表(如果不存在) - try: - cursor.execute(''' - CREATE TABLE IF NOT EXISTS data_sources ( - name TEXT PRIMARY KEY, - config TEXT NOT NULL, - update_at TEXT NULL - ) - ''') - conn.commit() - except Exception as e: - logger.error(f"Error creating data_sources table: {e}") - conn.close() - raise HTTPException(status_code=500, detail=f"Error creating configuration table: {str(e)}") - - return conn, cursor + @app.post("/folder-configs/remote") @@ -1613,9 +1500,9 @@ async def create_remote_folder_config(config: Dict[str, Any]): Returns: Created configuration with ID """ - # Set type to remote_folder if not provided + # Set type to folder if not provided if "type" not in config: - config["type"] = "remote_folder" + config["type"] = "folder" # Call the generic create_config function return await create_config(config) @@ -1660,29 +1547,22 @@ async def update_folder_config(config_id: str, config: Dict[str, Any]): if not config.get("table_name"): raise HTTPException(status_code=400, detail="Table name is required for database configuration") new_config_id = f"{config_type}_{config['database'].lower()}_{config['table_name'].lower()}" - elif config_type == "local_folder": - # 本地文件夹配置:使用文件夹路径生成ID + elif config_type == "folder": + # 文件夹配置:根据是否有host字段区分本地和远程 if not config.get("folder_path"): - raise HTTPException(status_code=400, detail="Folder path is required for local folder configuration") + raise HTTPException(status_code=400, detail="Folder path is required for folder configuration") # 替换路径中的特殊字符为下划线 folder_path = config["folder_path"].lower().replace("/", "_").replace(":", "_").replace("\\", "_") # 去除开头的下划线 if folder_path.startswith("_"): folder_path = folder_path[1:] - new_config_id = f"{config_type}_{folder_path}" - elif config_type == "remote_folder": - # 远程文件夹配置:使用主机和文件夹路径生成ID - # 端口不是唯一标识的必要元素,只用于连接配置 - if not config.get("host"): - raise HTTPException(status_code=400, detail="Host is required for remote folder configuration") - if not config.get("folder_path"): - raise HTTPException(status_code=400, detail="Folder path is required for remote folder configuration") - # 替换路径中的特殊字符为下划线 - folder_path = config["folder_path"].lower().replace("/", "_").replace(":", "_").replace("\\", "_") - # 去除开头的下划线 - if folder_path.startswith("_"): - folder_path = folder_path[1:] - new_config_id = f"{config_type}_{config['host'].lower()}_{folder_path}" + # 根据是否有host字段生成不同的ID + if config.get("host") and config["host"] != "localhost": + # 远程文件夹:使用主机和文件夹路径生成ID + new_config_id = f"{config_type}_{config['host'].lower()}_{folder_path}" + else: + # 本地文件夹:使用文件夹路径生成ID + new_config_id = f"{config_type}_{folder_path}" # 如果无法生成新的有意义的ID,保留原来的ID if not new_config_id: @@ -1708,7 +1588,7 @@ async def update_folder_config(config_id: str, config: Dict[str, Any]): global sync_manager if sync_manager is not None: # Create appropriate data source config object - from config import BaseDataSourceConfig, DatabaseDataSourceConfig, LocalFolderDataSourceConfig, RemoteFolderDataSourceConfig + from config import BaseDataSourceConfig, DatabaseDataSourceConfig, FolderDataSourceConfig if config_type == "database": source_config = DatabaseDataSourceConfig( @@ -1733,19 +1613,8 @@ async def update_folder_config(config_id: str, config: Dict[str, Any]): scp_username=config.get("scp_username"), scp_password=config.get("scp_password") ) - elif config_type == "local_folder": - source_config = LocalFolderDataSourceConfig( - name=new_config_id, - folder_path=config.get("folder_path"), - host=config.get("host"), - port=config.get("port"), - username=config.get("username"), - password=config.get("password"), - recursive=config.get("recursive", True), - ignore_patterns=config.get("ignore_patterns") - ) - elif config_type == "remote_folder": - source_config = RemoteFolderDataSourceConfig( + elif config_type == "folder": + source_config = FolderDataSourceConfig( name=new_config_id, folder_path=config.get("folder_path"), host=config.get("host"), diff --git a/config.py b/config.py index d4a286c..238b17f 100644 --- a/config.py +++ b/config.py @@ -6,6 +6,10 @@ import os from pydantic_settings import BaseSettings, SettingsConfigDict from typing import Optional, List, Dict, Any from pydantic import Field +from loguru import logger + + + class BaseDataSourceConfig: @@ -78,8 +82,8 @@ class DatabaseDataSourceConfig(BaseDataSourceConfig): self.scp_password = scp_password -class LocalFolderDataSourceConfig(BaseDataSourceConfig): - """Local folder data source configuration""" +class FolderDataSourceConfig(BaseDataSourceConfig): + """Folder data source configuration (local or remote via SSH/SFTP)""" def __init__( self, name: str, @@ -91,8 +95,8 @@ class LocalFolderDataSourceConfig(BaseDataSourceConfig): recursive: bool = True, ignore_patterns: Optional[List[str]] = None ): - super().__init__(name, "local_folder") - self.folder_path = folder_path # 本地文件夹路径 + super().__init__(name, "folder") + self.folder_path = folder_path # 文件夹路径 self.host = host # 主机地址 self.port = port # 主机端口 self.username = username # 主机用户名 @@ -101,29 +105,6 @@ class LocalFolderDataSourceConfig(BaseDataSourceConfig): self.ignore_patterns = ignore_patterns # 忽略的文件模式列表 -class RemoteFolderDataSourceConfig(BaseDataSourceConfig): - """Remote folder data source configuration (via SCP)""" - def __init__( - self, - name: str, - folder_path: str, - host: str, - port: int, - username: str, - password: Optional[str] = None, - recursive: bool = True, - ignore_patterns: Optional[List[str]] = None - ): - super().__init__(name, "remote_folder") - self.folder_path = folder_path # 远程文件夹路径 - self.host = host # 远程主机地址 - self.port = port # 远程主机端口 - self.username = username # 远程主机用户名 - self.password = password # 远程主机密码(可选) - self.recursive = recursive # 是否递归遍历子文件夹 - self.ignore_patterns = ignore_patterns # 忽略的文件模式列表 - - class Settings(BaseSettings): """ Application settings @@ -258,9 +239,9 @@ class Settings(BaseSettings): scp_username=ds_config.get('scp_username', None), scp_password=ds_config.get('scp_password', None) )) - elif source_type == 'local_folder': - # Create local folder data source - configs.append(LocalFolderDataSourceConfig( + elif source_type == 'folder': + # Create folder data source + configs.append(FolderDataSourceConfig( name=name, # 使用数据库表中的name列 folder_path=ds_config.get('folder_path', '.'), host=ds_config.get('host', 'localhost'), @@ -270,18 +251,6 @@ class Settings(BaseSettings): recursive=ds_config.get('recursive', True), ignore_patterns=ds_config.get('ignore_patterns', None) )) - elif source_type == 'remote_folder': - # Create remote folder data source - configs.append(RemoteFolderDataSourceConfig( - name=name, # 使用数据库表中的name列 - folder_path=ds_config.get('folder_path', '.'), - host=ds_config.get('host', ''), - port=ds_config.get('port', 22), - username=ds_config.get('username', ''), - password=ds_config.get('password', None), - recursive=ds_config.get('recursive', True), - ignore_patterns=ds_config.get('ignore_patterns', None) - )) else: from loguru import logger logger.warning(f"Unknown data source type: {source_type}, skipping") diff --git a/db_utils.py b/db_utils.py new file mode 100644 index 0000000..a8132e8 --- /dev/null +++ b/db_utils.py @@ -0,0 +1,129 @@ +""" +Database utilities for RAG system +""" +import sqlite3 +from pathlib import Path +from datetime import datetime +from typing import Tuple, Optional +from loguru import logger + + +def get_db_connection() -> Tuple[sqlite3.Connection, sqlite3.Cursor]: + """ + Get a SQLite database connection with data_sources table initialized + + Returns: + tuple: (connection, cursor) + """ + DATA_DIR = Path(__file__).parent / "data" + DATA_DIR.mkdir(parents=True, exist_ok=True) + DB_PATH = DATA_DIR / "sessions.db" + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # 检查并创建data_sources表(如果不存在) + try: + cursor.execute(''' + CREATE TABLE IF NOT EXISTS data_sources ( + name TEXT PRIMARY KEY, + config TEXT NOT NULL, + update_at TEXT NULL + ) + ''') + conn.commit() + except Exception as e: + logger.error(f"Error creating data_sources table: {e}") + conn.close() + raise + + return conn, cursor + + +def get_data_source_update_at(source_name: str) -> Optional[datetime]: + """ + Get update_at for a data source from data_sources table + + Args: + source_name: Name of the data source + + Returns: + datetime: Update time if found, None otherwise + """ + try: + conn, cursor = get_db_connection() + try: + cursor.execute('SELECT update_at FROM data_sources WHERE name = ?', (source_name,)) + result = cursor.fetchone() + if result and result[0]: + return datetime.fromisoformat(result[0]) + return None + finally: + conn.close() + except Exception as e: + logger.warning(f"Error reading update_at from data_sources: {e}") + return None + + +def update_data_source_update_at(source_name: str, update_at: datetime) -> bool: + """ + Update update_at for a data source in data_sources table + + Args: + source_name: Name of the data source + update_at: New update time + + Returns: + bool: True if update succeeded, False otherwise + """ + try: + conn, cursor = get_db_connection() + try: + cursor.execute('UPDATE data_sources SET update_at = ? WHERE name = ?', (update_at.isoformat(), source_name)) + conn.commit() + logger.info(f"Updated update_at in data_sources for {source_name}: {update_at}") + return True + finally: + conn.close() + except Exception as e: + logger.warning(f"Error updating update_at in data_sources: {e}") + return False + + +def init_session_db(): + """ + Initialize session database with users and sessions tables + """ + DATA_DIR = Path(__file__).parent / "data" + DATA_DIR.mkdir(parents=True, exist_ok=True) + DB_PATH = DATA_DIR / "sessions.db" + + conn = sqlite3.connect(DB_PATH) + try: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE, + password TEXT, + create_time TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_login TEXT, + title TEXT, + data TEXT, + update_time TEXT + ) + """ + ) + # Improve concurrency for writes + conn.execute("PRAGMA journal_mode=WAL;") + conn.execute("PRAGMA synchronous=NORMAL;") + conn.commit() + finally: + conn.close() diff --git a/rag/__init__.py b/rag/__init__.py index 4d6c46e..4549796 100644 --- a/rag/__init__.py +++ b/rag/__init__.py @@ -2,10 +2,9 @@ RAG module for retrieval and generation """ from .vector_store import VectorStoreManager -from .document_processor import DocumentProcessor from .rag_engine import RAGEngine from .file_parser import FileParser from .chunk_handler import OptimizedDeltaThinkFilter -__all__ = ["VectorStoreManager", "DocumentProcessor", "RAGEngine", "FileParser", "OptimizedDeltaThinkFilter"] +__all__ = ["VectorStoreManager", "RAGEngine", "FileParser", "OptimizedDeltaThinkFilter"] diff --git a/rag/document_processor.py b/rag/document_processor.py deleted file mode 100644 index 929e0dd..0000000 --- a/rag/document_processor.py +++ /dev/null @@ -1,202 +0,0 @@ -""" -Document processing and chunking module -""" -from llama_index.core import Document -from llama_index.core.node_parser import SentenceSplitter -from typing import List, Dict, Optional -from datetime import datetime, date -from loguru import logger -from config import settings - - -class DocumentProcessor: - """Process and chunk documents for RAG""" - - def __init__(self): - self.node_parser = SentenceSplitter( - chunk_size=settings.CHUNK_SIZE, - chunk_overlap=settings.CHUNK_OVERLAP - ) - - def mysql_doc_to_llamaindex_doc(self, mysql_doc: Dict, db_config=None) -> Document: - """ - Convert MySQL document to LlamaIndex Document - 将单个MySQL文档转换为LlamaIndex文档对象 - - Args: - mysql_doc: Dictionary from MySQL query result - db_config: DatabaseConfig object (optional, for backward compatibility) - - Returns: - LlamaIndex Document object - """ - # Get column names from db_config or fallback to default values - id_column = db_config.id_column if db_config else "id" - title_column = db_config.title_column if db_config else "title" - metadata_columns = db_config.metadata_columns if db_config else None - - doc_id = str(mysql_doc.get(id_column, "")) - - # 处理多个 content 列(支持合并多个列的内容) - if db_config: - # 使用配置的多个 content 列 - content_columns = db_config.content_columns - content_separator = db_config.content_separator - else: - # 向后兼容:使用单个 content_column - content_columns = ["content"] # Default to "content" column - content_separator = "\n" - - # 合并所有 content 列的内容 - content_parts = [] - for col in content_columns: - col_value = mysql_doc.get(col, "") - if col_value: - content_parts.append(str(col_value)) - - # 用指定的分隔符连接多个列的内容 - content = content_separator.join(content_parts) if content_parts else "" - - title = mysql_doc.get(title_column, "") if title_column else None - - # Create unique doc_id with database source and table name to avoid conflicts - # Format: {db_source}_{table_name}_{id} to ensure uniqueness across multiple tables - # This must match the format used in sync_service.py - if '_db_source' in mysql_doc and '_db_table' in mysql_doc: - unique_doc_id = f"{mysql_doc['_db_source']}_{mysql_doc['_db_table']}_{doc_id}" - elif '_db_source' in mysql_doc: - unique_doc_id = f"{mysql_doc['_db_source']}_{doc_id}" - else: - unique_doc_id = doc_id - - # Build metadata - # Note: metadata['doc_id'] should use unique_doc_id to match sync_service.py logic - # Format: {db_source}_{table_name}_{id} to ensure uniqueness across multiple tables - metadata = { - "doc_id": unique_doc_id, # Format: {db_source}_{table_name}_{id} - "original_doc_id": doc_id, # Keep original ID for reference - "source": "mysql", - "content_column": db_config.content_column # 额外存入 content_column - } - - # Add database source information if available - if '_db_source' in mysql_doc: - metadata['db_source'] = mysql_doc['_db_source'] - if '_db_database' in mysql_doc: - metadata['db_database'] = mysql_doc['_db_database'] - if '_db_table' in mysql_doc: - metadata['db_table'] = mysql_doc['_db_table'] - - if title: - metadata["title"] = title - - # Add additional metadata columns if specified - if metadata_columns: - metadata_cols = [col.strip() for col in metadata_columns.split(",")] - for col in metadata_cols: - if col in mysql_doc and not col.startswith('_'): - value = mysql_doc[col] - # Convert datetime/date objects to strings for ChromaDB compatibility - # ChromaDB metadata only supports: str, int, float, bool - if isinstance(value, (datetime, date)): - # Format datetime/date as ISO format string - metadata[col] = value.isoformat() - elif value is not None: - # Convert other types to string if not already a supported type - if not isinstance(value, (str, int, float, bool)): - metadata[col] = str(value) - else: - metadata[col] = value - - # Create Document - # Note: LlamaIndex uses id_ (not doc_id) as the unique identifier for Document - # See: https://docs.llamaindex.org.cn/en/stable/module_guides/indexing/document_management/#update - doc = Document( - text=content, - id_=unique_doc_id, # Use id_ as per LlamaIndex documentation - metadata=metadata - ) - - return doc - - def process_documents(self, mysql_docs: List[Dict], db_config=None) -> List[Document]: - """ - Process multiple MySQL documents into LlamaIndex Documents - 处理多个MySQL文档,将其转换为LlamaIndex文档对象 - Args: - mysql_docs: List of MySQL document dictionaries - db_config: DatabaseConfig object (optional, for backward compatibility) - - Returns: - List of LlamaIndex Document objects - """ - documents = [] - id_column = db_config.id_column if db_config else "id" - for mysql_doc in mysql_docs: - try: - doc = self.mysql_doc_to_llamaindex_doc(mysql_doc, db_config) - if len(doc.text.strip()) >= 100: - documents.append(doc) - else: - logger.warning(f"跳过过短文档 (id: {doc.id_}),内容长度: {len(doc.text)} 字符") - except Exception as e: - doc_id = mysql_doc.get(id_column, mysql_doc.get('_db_source', 'unknown')) - logger.error(f"Error processing document {doc_id}: {e}") - continue - - logger.info(f"Processed {len(documents)} documents") - return documents - - def chunk_documents(self, documents: List[Document]) -> List[Document]: - """ - Chunk documents into smaller pieces - - Args: - documents: List of Document objects - doc = Document( - text=content, - id_=unique_doc_id, # Use id_ as per LlamaIndex documentation - metadata=metadata - ) - - Returns: - List of chunked Document objects - """ - chunked_docs = [] - for doc in documents: - try: - nodes = self.node_parser.get_nodes_from_documents([doc]) - # Get unique_doc_id from doc.id_ or metadata['doc_id'] - # This is the original document's unique ID (format: {db_source}_{table_name}_{id}) - unique_doc_id = getattr(doc, 'id_', None) or (doc.metadata.get('doc_id') if doc.metadata else None) - if not unique_doc_id: - # Fallback: use node.node_id if unique_doc_id is not available - unique_doc_id = f"doc_{id(doc)}" - logger.warning(f"Document has no id_ or doc_id in metadata, using fallback: {unique_doc_id}") - - # Convert nodes back to documents for storage - # Use unique_doc_id + chunk_index as id_ for easier identification and duplicate checking - # Format: {db_source}_{table_name}_{id}_chunk_{index} - for index, node in enumerate(nodes): - chunk_id = f"{unique_doc_id}_chunk_{index}" - chunked_doc = Document( - text=node.text, - id_=chunk_id, # Use unique_doc_id + chunk_index for better traceability - metadata={ - **doc.metadata, - "chunk_id": chunk_id, - "chunk_index": index, - "total_chunks": len(nodes), - "original_node_id": node.node_id # Keep original node_id for reference - } - ) - chunked_docs.append(chunked_doc) - except Exception as e: - # Access id_ property (not doc_id) for error logging - doc_id = getattr(doc, 'id_', getattr(doc, 'doc_id', 'unknown')) - logger.error(f"Error chunking document {doc_id}: {e}") - continue - - logger.info(f"Chunked {len(documents)} documents into {len(chunked_docs)} chunks") - return chunked_docs - diff --git a/rag/file_parser.py b/rag/file_parser.py index 7ff858b..c55956f 100644 --- a/rag/file_parser.py +++ b/rag/file_parser.py @@ -3,6 +3,7 @@ File parsing module for various document formats """ import os import requests +from config import settings import mimetypes from typing import List, Dict, Optional from pathlib import Path @@ -42,74 +43,46 @@ class FileParser: ext = Path(filename).suffix.lower() # 获取文件类型并转为小写 return ext in self.SUPPORTED_EXTENSIONS - def _generate_doc_id(self, filename: str, host: Optional[str] = None) -> str: + def parse_file_content(self, content: bytes, file_path: str, doc_id: Optional[str] = None, metadata: Optional[Dict] = None, host: Optional[str] = None) -> List[Document]: """ - Generate a unique doc_id based on host address and filename + Parse file content from bytes into LlamaIndex Documents Args: - filename: Name of the file - host: Optional host address (if not provided, will use local machine IP) - - Returns: - Unique doc_id generated from host address and filename - """ - # 使用传入的host参数(如果提供),否则获取本地机器IP地址 - if host: - host_address = host - else: - import socket - try: - # 获取机器IP地址 - hostname = socket.gethostname() - host_address = socket.gethostbyname(hostname) - except: - # 如果无法获取IP,使用默认值 - host_address = 'unknown' - - # 使用主机地址和完整文件路径生成doc_id,确保唯一性 - # 替换路径中的特殊字符,避免生成无效的doc_id - sanitized_path = filename.replace('/', '_').replace('\\', '_').replace(':', '_').replace(' ', '_') - return f"{host_address}_{sanitized_path}" - - def parse_file(self, file_path: str, doc_id: Optional[str] = None, metadata: Optional[Dict] = None, host: Optional[str] = None) -> List[Document]: - """ - Parse a file into LlamaIndex Documents - - Args: - file_path: Path to the file - doc_id: Optional document ID (if not provided, will use filename and host) + content: File content as bytes + file_path: Original file path (for format detection and metadata) + doc_id: Optional document ID metadata: Optional metadata to add to documents - host: Optional host address (if not provided, will use local machine IP) + host: Optional host address (for remote files, use the remote host address) Returns: List of LlamaIndex Document objects """ - if not os.path.exists(file_path): - raise FileNotFoundError(f"File not found: {file_path}") + import tempfile - filename = os.path.basename(file_path) - ext = Path(file_path).suffix.lower() + # Extract filename from path + original_filename = Path(file_path).name + ext = Path(file_path).suffix.lower() # 确定文件扩展名 + if not ext: + # 无拓展名时,Try to detect from mimetype + mime_type, _ = mimetypes.guess_type(file_path) + if mime_type: + ext = mimetypes.guess_extension(mime_type) or '.txt' + else: + ext = '.txt' + + # Check if file format is supported if not self.is_supported(file_path): raise ValueError(f"Unsupported file format: {ext}. Supported formats: {', '.join(self.SUPPORTED_EXTENSIONS)}") - # 生成基于主机地址和真实文件名的doc_id(如果未提供) - if not doc_id: - doc_id = self._generate_doc_id(filename, host) - try: - logger.info(f"Starting to parse file: {filename} (type: {ext})\n") + logger.info(f"Starting to parse file: {original_filename} (type: {ext})\n") # 处理 .doc 文件:通过 soffice-service 转换为 .docx if ext == '.doc': logger.info(f"检测到 .doc 文件,开始转换为 .docx 格式: {file_path}") - # 读取 .doc 文件内容 - with open(file_path, 'rb') as f: - file_bytes = f.read() # 上传文件到 soffice-service 的 convert 接口 - import requests - from config import settings - files = {'file': (filename, file_bytes, 'application/msword')} + files = {'file': (original_filename, content, 'application/msword')} soffice_url = f"http://{settings.SOFFICE_HOST}:{settings.SOFFICE_PORT}/convert" response = requests.post(soffice_url, files=files, timeout=60) try: @@ -118,17 +91,14 @@ class FileParser: logger.error(f"转换请求失败: {e}") raise # 将转换后的 docx 内容保存到临时文件进行解析 - import tempfile with tempfile.NamedTemporaryFile(delete=False, suffix='.docx') as tmp_docx_file: tmp_docx_file.write(response.content) - tmp_path = tmp_docx_file.name - # 使用转换后的临时文件路径 - parse_path = tmp_path - cleanup_tmp = True + parse_path = tmp_docx_file.name else: - # 对于其他文件格式,直接使用原始文件路径 - parse_path = file_path - cleanup_tmp = False + # 对于其他文件格式,创建临时文件保存内容 + with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_file: + tmp_file.write(content) + parse_path = tmp_file.name # Use LlamaIndex's SimpleDirectoryReader for parsing # It supports many formats out of the box @@ -144,27 +114,18 @@ class FileParser: # Validate that we got documents if not documents: - logger.warning(f"No documents extracted from file: {filename}") - raise ValueError(f"Failed to extract content from {filename}. The file may be empty or in an unsupported format.") - - # Check if documents have content - empty_docs = [] - for i, doc in enumerate(documents): - if not doc.text or not doc.text.strip(): - empty_docs.append(i) - logger.warning(f"Document {i} from {filename} has empty content") - - if len(empty_docs) == len(documents): - raise ValueError(f"All documents from {filename} have empty content. The file may not contain readable text.") + logger.warning(f"No documents extracted from file: {original_filename}") + raise ValueError(f"Failed to extract content from {original_filename}. The file may be empty or in an unsupported format.") # Add metadata to each document base_metadata = { 'source': 'file_upload', - 'file_name': filename, - 'file_path': file_path, 'file_type': ext.lstrip('.'), + 'file_name': original_filename, + 'file_path': file_path, # Use the original file path } + # Update with provided metadata if any if metadata: base_metadata.update(metadata) @@ -173,7 +134,7 @@ class FileParser: for i, doc in enumerate(documents): # Skip empty documents if not doc.text or not doc.text.strip(): - logger.warning(f"Skipping empty document {i} from {filename}") + logger.warning(f"Skipping empty document {i} from {original_filename}") continue # Set document ID @@ -189,70 +150,18 @@ class FileParser: valid_documents.append(doc) - if not valid_documents: - raise ValueError(f"No valid documents extracted from {filename}. All documents are empty.") - - logger.info(f"Successfully parsed file {filename}: {len(valid_documents)} valid document(s) (total: {len(documents)}, skipped empty: {len(documents) - len(valid_documents)})\n") + logger.info(f"Successfully parsed file {original_filename}: {len(valid_documents)} valid document(s) (total: {len(documents)}, skipped empty: {len(documents) - len(valid_documents)})\n") return valid_documents except Exception as e: logger.error(f"Error parsing file {file_path}: {e}", exc_info=True) raise finally: - # 清理临时文件(如果有) - if 'cleanup_tmp' in locals() and cleanup_tmp: - try: - import os - os.unlink(tmp_path) - logger.debug(f"Cleaned up temporary file: {tmp_path}") - except Exception as e: - logger.warning(f"Failed to delete temporary file {tmp_path}: {e}") - - def parse_file_content(self, content: bytes, filename: str, doc_id: Optional[str] = None, metadata: Optional[Dict] = None, host: Optional[str] = None) -> List[Document]: - """ - Parse file content from bytes into LlamaIndex Documents - - Args: - content: File content as bytes - filename: Original filename (for format detection) - doc_id: Optional document ID - metadata: Optional metadata to add to documents - host: Optional host address (for remote files, use the remote host address) - - Returns: - List of LlamaIndex Document objects - """ - import tempfile - - # Create temporary file - ext = Path(filename).suffix.lower() # 确定文件扩展名 - if not ext: - # 无拓展名时,Try to detect from mimetype - mime_type, _ = mimetypes.guess_type(filename) - if mime_type: - ext = mimetypes.guess_extension(mime_type) or '.txt' - else: - ext = '.txt' - - # 生成基于主机地址和真实文件名的doc_id(如果未提供) - if not doc_id: - doc_id = self._generate_doc_id(filename, host) - - # Use tempfile to save content and parse - with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_file: - tmp_file.write(content) - tmp_path = tmp_file.name - - try: - # Parse the temporary file - documents = self.parse_file(tmp_path, doc_id=doc_id, metadata=metadata) - return documents - finally: - # Clean up temporary file try: - os.unlink(tmp_path) + os.unlink(parse_path) + logger.debug(f"Cleaned up temporary file: {parse_path}") except Exception as e: - logger.warning(f"Failed to delete temporary file {tmp_path}: {e}") + logger.warning(f"Failed to delete temporary file {parse_path}: {e}") diff --git a/rag/vector_store.py b/rag/vector_store.py index b98b24d..60ac5a3 100644 --- a/rag/vector_store.py +++ b/rag/vector_store.py @@ -386,7 +386,6 @@ class VectorStoreManager: skipped_count = 0 if skip_existing: - existing_ids = self.get_existing_doc_ids() for doc in documents: # Get document id_ (for chunks, this is {unique_doc_id}_chunk_{index}) # For non-chunk documents, this is {unique_doc_id} @@ -395,7 +394,7 @@ class VectorStoreManager: if not doc_id_attr: # Document has no id_, add it (will be processed) new_documents.append(doc) - elif doc_id_attr in existing_ids: + elif self.document_exists(doc_id_attr): # This specific chunk/document already exists, skip it skipped_count += 1 else: diff --git a/static/config/index.html b/static/config/index.html index 803848a..0ec14d1 100644 --- a/static/config/index.html +++ b/static/config/index.html @@ -4,8 +4,8 @@ RAG 配置管理 - - + +
@@ -18,8 +18,7 @@
@@ -28,9 +27,6 @@ - @@ -78,8 +74,7 @@ `; formElement.appendChild(basicSection); // 根据配置类型生成相应的表单字段 - if (config.type === 'local_folder' || config.type === 'remote_folder') { + if (config.type === 'folder') { const folderSection = document.createElement('div'); folderSection.innerHTML = `

文件夹配置

@@ -257,8 +250,8 @@ function generateConfigForm(config) { } - // 为local_folder和remote_folder添加SSH连接配置 - if (config.type === 'remote_folder' || config.type === 'local_folder') { + // 为folder添加SSH连接配置 + if (config.type === 'folder') { const sshSection = document.createElement('div'); sshSection.innerHTML = `

SSH连接配置

@@ -1042,14 +1035,7 @@ async function handleAddConfigDirectly() { id_column: '', content_column: '' }; - } else if (configType === 'local_folder') { - tempConfig = { - type: configType, - folder_path: '', - recursive: true, - ignore_patterns: [] - }; - } else if (configType === 'remote_folder') { + } else if (configType === 'folder') { tempConfig = { type: configType, folder_path: '', @@ -1108,15 +1094,7 @@ async function handleAddConfig(event) { id_column: '', content_column: '' }; - } else if (configType === 'local_folder') { - configData = { - name: configName, - type: configType, - folder_path: '', - recursive: true, - ignore_patterns: [] - }; - } else if (configType === 'remote_folder') { + } else if (configType === 'folder') { configData = { name: configName, type: configType, @@ -1191,17 +1169,14 @@ async function saveConfig() { if (formData.type === 'database') { // 数据库配置:database_数据库名_表名 generatedName = `database_${formData.database || 'unknown'}_${formData.table_name || 'unknown'}`; - } else if (formData.type === 'local_folder') { - // 本地文件夹:local_folder_文件夹路径(替换特殊字符) + } else if (formData.type === 'folder') { + // 文件夹:folder_主机_文件夹路径(替换特殊字符) const folderName = formData.folder_path ? formData.folder_path.replace(/[\\/:*?"<>|]/g, '_') : 'unknown'; - generatedName = `local_folder_${folderName}`; - } else if (formData.type === 'remote_folder') { - // 远程文件夹:remote_folder_主机_文件夹路径(替换特殊字符) - const folderName = formData.folder_path ? formData.folder_path.replace(/[\\/:*?"<>|]/g, '_') : 'unknown'; - generatedName = `remote_folder_${formData.host || 'unknown'}_${folderName}`; + generatedName = `folder_${formData.host || 'unknown'}_${folderName}`; } else { - // 默认名称 - generatedName = `default_config`; + // 不支持的配置类型 + alert('不支持的配置类型'); + return; } } @@ -1218,11 +1193,8 @@ async function saveConfig() { if (!formData.content_column || formData.content_column.trim() === '') { missingFields.push('内容列'); } - } else if (formData.type === 'local_folder' || formData.type === 'remote_folder') { + } else if (formData.type === 'folder') { if (!formData.folder_path) missingFields.push('文件夹路径'); - } - - if (formData.type === 'remote_folder' || formData.type === 'local_folder') { if (!formData.host) missingFields.push('主机地址'); if (!formData.port) missingFields.push('端口'); if (!formData.username) missingFields.push('用户名'); @@ -1268,6 +1240,9 @@ async function saveConfig() { // 获取保存/更新后的配置 const updatedConfig = await response.json(); + // 检查是否是新配置(POST) + const isNewConfig = currentConfig.id === null; + // 更新当前配置的ID currentConfig.id = updatedConfig.id; @@ -1282,7 +1257,7 @@ async function saveConfig() { // 数据源名称是配置的ID,而不是config中的name字段 const sourceName = updatedConfig.id; - if (currentConfig.id === null) { + if (isNewConfig) { // 新配置(POST):保存成功后,立刻启动数据源的同步操作 alert('配置保存成功,正在启动同步...'); @@ -1359,21 +1334,18 @@ function collectFormData() { formData.type = document.getElementById('formType').value; // 文件夹配置 - if (formData.type === 'local_folder' || formData.type === 'remote_folder') { + if (formData.type === 'folder') { formData.folder_path = document.getElementById('formFolderPath').value; formData.recursive = document.getElementById('formRecursive').checked; const ignorePatterns = document.getElementById('formIgnorePatterns').value; formData.ignore_patterns = ignorePatterns ? ignorePatterns.split(',').map(p => p.trim()) : []; - } - - // 远程连接配置和本地文件夹的SSH配置 - if (formData.type === 'remote_folder' || formData.type === 'local_folder') { + + // 文件夹的SSH配置 formData.host = document.getElementById('formHost').value; formData.port = parseInt(document.getElementById('formPort').value); formData.username = document.getElementById('formUsername').value; formData.password = document.getElementById('formPassword').value; - } // 数据库配置 @@ -1396,19 +1368,7 @@ function collectFormData() { formData.mysql_user = document.getElementById('formMysqlUser').value; formData.mysql_password = document.getElementById('formMysqlPassword').value; - // 仅当配置类型不是database时,才处理文件列 - if (formData.type !== 'database') { - formData.file_column = document.getElementById('formFileColumn').value; - - // 确保文件列同时出现在内容列中 - if (formData.file_column && formData.file_column !== '') { - const contentColumns = formData.content_column ? formData.content_column.split(',') : []; - if (!contentColumns.includes(formData.file_column)) { - contentColumns.push(formData.file_column); - formData.content_column = contentColumns.join(','); - } - } - } + } diff --git a/static/config/style.css b/static/config/style.css index 85f109a..b23a011 100644 --- a/static/config/style.css +++ b/static/config/style.css @@ -192,16 +192,11 @@ body { color: #1976d2; } -.config-item[data-type="local_folder"] .config-item-icon { +.config-item[data-type="folder"] .config-item-icon { background-color: #e8f5e9; color: #388e3c; } -.config-item[data-type="remote_folder"] .config-item-icon { - background-color: #fff3e0; - color: #f57c00; -} - .config-item-details { flex: 1; } diff --git a/sync/__init__.py b/sync/__init__.py index 7c6c88a..305bf76 100644 --- a/sync/__init__.py +++ b/sync/__init__.py @@ -1,13 +1,11 @@ """Synchronization modules for all data sources""" from .base_sync import BaseSync, get_sync_class from .mysql_sync import MySQLSync -from .local_folder_sync import LocalFolderSync -from .remote_folder_sync import RemoteFolderSync +from .folder_sync import FolderSync __all__ = [ 'BaseSync', 'get_sync_class', 'MySQLSync', - 'LocalFolderSync', - 'RemoteFolderSync' + 'FolderSync' ] diff --git a/sync/base_sync.py b/sync/base_sync.py index 31541ef..7a9c9a5 100644 --- a/sync/base_sync.py +++ b/sync/base_sync.py @@ -8,15 +8,17 @@ from rag.file_parser import FileParser class BaseSync(ABC): """Abstract base class for all data source syncers""" - def __init__(self, config: BaseDataSourceConfig): + def __init__(self, config: BaseDataSourceConfig, vector_store_manager=None): """ Initialize sync with data source configuration Args: config: Configuration for the data source + vector_store_manager: Vector store manager for document existence checks """ self.config = config self.file_parser = FileParser() + self.vector_store_manager = vector_store_manager def _get_file_bytes(self, file_path: str, source_type: str, source_config: Optional[Dict[str, Any]] = None) -> Optional[bytes]: """ @@ -89,33 +91,7 @@ class BaseSync(ABC): logger.error(f"Error getting file bytes for {file_path}: {e}") return None - def _parse_file_content(self, file_bytes: bytes, file_path: str, host: Optional[str] = None) -> Optional[str]: - """ - Parse file content using FileParser class - - Args: - file_bytes: File content as bytes - file_path: Path to the file (for getting file extension) - host: Optional host address (for remote files, use the remote host address) - - Returns: - Parsed file content if successfully parsed, None otherwise - """ - try: - # 使用 FileParser 解析文件内容 - # parse_file_content 方法的参数顺序是 content, filename - documents = self.file_parser.parse_file_content(file_bytes, file_path, host=host) - if documents: - # 合并所有文档内容 - return '\n\n'.join(doc.text for doc in documents if doc.text) - else: - from loguru import logger - logger.warning(f"No content extracted from {file_path}") - return None - except Exception as e: - from loguru import logger - logger.error(f"Error parsing file content for {file_path}: {e}") - return None + @abstractmethod def fetch_all_documents(self) -> List[Dict[str, Any]]: @@ -128,13 +104,121 @@ class BaseSync(ABC): pass @abstractmethod - def fetch_new_documents(self, last_sync_time=None, synced_doc_ids=None) -> List[Dict[str, Any]]: + def doc_to_llamaindex_doc(self, doc: Dict) -> 'Document': + """ + Convert data source document to LlamaIndex Document + + Args: + doc: Document from the data source + + Returns: + LlamaIndex Document object + """ + pass + + def process_documents(self, docs: List[Dict]) -> List['Document']: + """ + Process multiple documents into LlamaIndex Documents + + Args: + docs: List of documents from the data source + + Returns: + List of LlamaIndex Document objects + """ + from loguru import logger + documents = [] + for doc in docs: + try: + llamaindex_doc = self.doc_to_llamaindex_doc(doc) + if len(llamaindex_doc.text.strip()) >= 1: + documents.append(llamaindex_doc) + else: + logger.warning(f"跳过过短文档 (id: {llamaindex_doc.id_}),内容长度: {len(llamaindex_doc.text)} 字符") + except Exception as e: + doc_id = doc.get('id', 'unknown') + logger.error(f"Error processing document {doc_id}: {e}") + continue + + logger.info(f"Processed {len(documents)} documents") + return documents + + @staticmethod + def chunk_documents(documents: List['Document']) -> List['Document']: + """ + Chunk documents into smaller pieces + + Args: + documents: List of Document objects to chunk + + Returns: + List of chunked Document objects + """ + from llama_index.core import Document + from llama_index.core.node_parser import SentenceSplitter + from loguru import logger + from config import settings + + node_parser = SentenceSplitter( + chunk_size=settings.CHUNK_SIZE, + chunk_overlap=settings.CHUNK_OVERLAP + ) + + chunked_docs = [] + for doc in documents: + try: + nodes = node_parser.get_nodes_from_documents([doc]) + # Get unique_doc_id from doc.id_ or metadata['doc_id'] + unique_doc_id = getattr(doc, 'id_', None) or (doc.metadata.get('doc_id') if doc.metadata else None) + if not unique_doc_id: + # Fallback: use node.node_id if unique_doc_id is not available + unique_doc_id = f"doc_{id(doc)}" + logger.warning(f"Document has no id_ or doc_id in metadata, using fallback: {unique_doc_id}") + + # Convert nodes back to documents for storage + for index, node in enumerate(nodes): + chunk_id = f"{unique_doc_id}_chunk_{index}" + chunked_doc = Document( + text=node.text, + id_=chunk_id, + metadata={ + **doc.metadata, + "chunk_id": chunk_id, + "chunk_index": index, + "total_chunks": len(nodes), + "original_node_id": node.node_id + } + ) + chunked_docs.append(chunked_doc) + except Exception as e: + # Access id_ property (not doc_id) for error logging + doc_id = getattr(doc, 'id_', getattr(doc, 'doc_id', 'unknown')) + logger.error(f"Error chunking document {doc_id}: {e}") + continue + + logger.info(f"Chunked {len(documents)} documents into {len(chunked_docs)} chunks") + return chunked_docs + + @abstractmethod + def generate_doc_id(self, identifier: str) -> str: + """ + Generate a unique document ID for different data sources + + Args: + identifier: Unique identifier for the document (file path, record ID, etc.) + + Returns: + Unique document ID + """ + pass + + @abstractmethod + def fetch_new_documents(self, last_sync_time=None) -> List[Dict[str, Any]]: """ Fetch new/updated documents from the data source since last sync time Args: last_sync_time: Last synchronization time - synced_doc_ids: Set of document IDs that have already been synced Returns: List of new/updated documents @@ -192,7 +276,7 @@ def get_sync_class(source_type: str) -> type[BaseSync]: Get the appropriate sync class based on data source type Args: - source_type: Type of data source (database, local_folder, remote_folder) + source_type: Type of data source (database, folder) Returns: Sync class corresponding to the data source type @@ -201,13 +285,11 @@ def get_sync_class(source_type: str) -> type[BaseSync]: ValueError: If source type is not supported """ from sync.mysql_sync import MySQLSync - from sync.local_folder_sync import LocalFolderSync - from sync.remote_folder_sync import RemoteFolderSync + from sync.folder_sync import FolderSync sync_classes = { 'database': MySQLSync, # Currently only MySQL, but can be extended - 'local_folder': LocalFolderSync, - 'remote_folder': RemoteFolderSync + 'folder': FolderSync } if source_type not in sync_classes: diff --git a/sync/folder_sync.py b/sync/folder_sync.py new file mode 100644 index 0000000..26505ab --- /dev/null +++ b/sync/folder_sync.py @@ -0,0 +1,384 @@ +"""Folder synchronization implementation for local and remote folders""" +import os +import re +from typing import List, Dict, Any, Set +from datetime import datetime +from pathlib import Path +from loguru import logger +from config import BaseDataSourceConfig +from sync.base_sync import BaseSync +from rag.file_parser import FileParser +from llama_index.core import Document + + +class FolderSync(BaseSync): + """Handle synchronization between folder (local or remote) and ChromaDB""" + + def __init__(self, config: BaseDataSourceConfig): + """ + Initialize folder sync with configuration + + Args: + config: Folder configuration + """ + super().__init__(config) + self.file_parser = FileParser() + self._ssh_client = None + self._sftp_client = None + + def fetch_all_documents(self, last_sync_time=None) -> List[Dict[str, Any]]: + """ + Fetch documents from the folder + + Args: + last_sync_time: Last synchronization time (for incremental sync) + + Returns: + List of documents + """ + documents = [] + self._connect() + try: + files = self._get_all_files() + + for file_path in files: + + # Parse file content + try: + # 检查文件扩展名是否在支持的列表中 + if Path(file_path).suffix.lower() not in FileParser.SUPPORTED_EXTENSIONS: + logger.debug(f"Skipping unsupported file: {file_path}") + continue + + # Generate document ID + doc_id = self.generate_doc_id(file_path) + + if last_sync_time is not None: + # Check if document has already been synced + if self.vector_store_manager and self.vector_store_manager.document_exists(doc_id): + # If document is already synced, check if it's been modified since last sync + if last_sync_time: + file_stat = self._sftp_client.stat(file_path) + file_mtime = datetime.fromtimestamp(file_stat.st_mtime) + # Skip if not modified since last sync + if file_mtime <= last_sync_time: + continue + + # Read file content + with self._sftp_client.open(file_path, 'rb') as f: + file_bytes = f.read() + + # Parse file content + if file_bytes: + try: + # 使用 FileParser 解析文件内容 + parsed_docs = self.file_parser.parse_file_content(file_bytes, file_path, doc_id=doc_id, host=self.config.host) + if parsed_docs: + # 合并所有文档内容 + content = '\n\n'.join(doc.text for doc in parsed_docs if doc.text) + else: + logger.warning(f"No content extracted from {file_path}") + content = f"[无法读取文件:{Path(file_path).name}]" + except Exception as e: + logger.error(f"Error parsing file content for {file_path}: {e}") + content = f"[无法读取文件:{Path(file_path).name}]" + else: + content = f"[无法读取文件:{Path(file_path).name}]" + + # Build document + document = { + 'id': doc_id, + 'content': content, + 'metadata': { + 'file_path': str(file_path), + 'update_time': datetime.fromtimestamp(self._sftp_client.stat(file_path).st_mtime), + 'host': self.config.host + } + } + documents.append(document) + except Exception as e: + logger.error(f"Error processing file {file_path}: {e}") + finally: + self._disconnect() + + return documents + + def fetch_new_documents(self, last_sync_time=None) -> List[Dict[str, Any]]: + """ + Fetch new/updated documents from the folder since last sync time + + Args: + last_sync_time: Last synchronization time + + Returns: + List of new/updated documents + """ + # Call fetch_all_documents which now handles document existence checks + return self.fetch_all_documents(last_sync_time) + + def get_synced_document_ids(self) -> Set[str]: + """ + Get IDs of all files in the folder + + Returns: + Set of file paths (as document IDs) + """ + self._connect() + try: + files = self._get_all_files() + return set(files) + finally: + self._disconnect() + + def generate_doc_id(self, file_path: str) -> str: + """ + Generate a unique document ID for files + + Args: + file_path: Path to the file + + Returns: + Unique document ID based on host IP and file path + """ + # 使用配置中的主机地址 + host_address = self.config.host or 'unknown' + + # 替换路径中的特殊字符,避免生成无效的doc_id + sanitized_path = file_path.replace('/', '_').replace('\\', '_').replace(':', '_').replace(' ', '_') + return f"{host_address}_{sanitized_path}" + + def doc_to_llamaindex_doc(self, doc: Dict) -> 'Document': + """ + Convert folder document to LlamaIndex Document + + Args: + doc: Folder document dictionary + + Returns: + LlamaIndex Document object + """ + content = doc.get('content', "") + doc_id = doc.get('id', "") + metadata = doc.get('metadata', {}) + + # Ensure metadata has source information + metadata['source'] = 'folder' + metadata['host'] = self.config.host + + # Create Document + return Document( + text=content, + id_=doc_id, + metadata=metadata + ) + + def _connect(self): + """ + Connect to the server via SSH/SFTP + + Raises: + Exception: If connection fails with detailed error message + """ + import paramiko + + self._ssh_client = paramiko.SSHClient() + self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + # Connect to SSH server + + # 获取用户名 + username = self.config.username + if not username: + raise Exception("SSH connection failed: Username is required") + + # 建立SSH连接的参数 + ssh_params = { + 'hostname': self.config.host, + 'port': self.config.port or 22, + 'username': username, + 'password': self.config.password, + 'timeout': 10, + 'allow_agent': True, # 允许使用SSH代理 + 'look_for_keys': False # 禁用查找本地密钥文件 + } + + try: + # 连接到SSH服务器 + self._ssh_client.connect(**ssh_params) + + # Create SFTP client + self._sftp_client = self._ssh_client.open_sftp() + except paramiko.AuthenticationException: + raise Exception(f"SSH connection failed: Authentication failed for user {username} on {self.config.host}") + except paramiko.SSHException as ssh_error: + raise Exception(f"SSH connection failed: {str(ssh_error)}") + except Exception as e: + raise Exception(f"Connection failed: {str(e)}") + + def _disconnect(self): + """ + Disconnect from the server + """ + if self._sftp_client: + self._sftp_client.close() + self._sftp_client = None + + if self._ssh_client: + self._ssh_client.close() + self._ssh_client = None + + def _get_all_files(self) -> List[str]: + """ + Get all files in the folder + + Returns: + List of file paths + + Note: + This method assumes that a connection has already been established by the caller + """ + files = [] + try: + # 直接调用 _get_files_recursive,使用已经建立的连接 + self._get_files_recursive(self.config.folder_path, files) + except Exception as e: + logger.error(f"Error getting all files: {e}") + return files + + def _get_files_recursive(self, folder_path: str, files: List[str]): + """ + Recursively get all files in the folder + + Args: + folder_path: Current folder path + files: List to store found files + """ + try: + items = self._sftp_client.listdir_attr(folder_path) + + for item in items: + item_path = os.path.join(folder_path, item.filename) + + if item.filename not in ('.', '..'): + if item.st_mode & 0o040000: # Check if it's a directory + if self.config.recursive: + self._get_files_recursive(item_path, files) + else: + # Check if file should be ignored + if not self._should_ignore_file(item_path): + files.append(item_path) + except Exception as e: + logger.error(f"Error listing folder {folder_path}: {e}") + + def _should_ignore_file(self, file_path: str) -> bool: + """ + Check if file should be ignored based on ignore patterns + + Args: + file_path: File path to check + + Returns: + True if file should be ignored, False otherwise + """ + if not hasattr(self.config, 'ignore_patterns') or not self.config.ignore_patterns: + return False + + # Get relative path from folder root + relative_path = os.path.relpath(file_path, self.config.folder_path) + + for pattern in self.config.ignore_patterns: + if self._match_pattern(relative_path, pattern): + return True + + return False + + def _match_pattern(self, path: str, pattern: str) -> bool: + """ + Match a path against a pattern (similar to .gitignore) + + Args: + path: Path to match + pattern: Pattern to match against + + Returns: + True if path matches pattern, False otherwise + """ + # Convert glob pattern to regex + regex_pattern = pattern + regex_pattern = regex_pattern.replace('.', r'\.') + regex_pattern = regex_pattern.replace('*', r'.*') + regex_pattern = regex_pattern.replace('?', r'.') + + # Handle directory patterns + if pattern.endswith('/'): + regex_pattern = f'^{regex_pattern}.*$' + else: + regex_pattern = f'^{regex_pattern}$' + + return bool(re.match(regex_pattern, path)) + + @staticmethod + def check_data_source_exists(config: BaseDataSourceConfig) -> bool: + """ + Check if the folder exists and is accessible + + Args: + config: Folder configuration + + Returns: + True if folder exists and is accessible, False otherwise + + Raises: + Exception: If connection fails with detailed error message + """ + import paramiko + + ssh_client = None + sftp_client = None + try: + # 检查必要的配置 + if not config.host: + raise Exception("SSH connection failed: Host is required") + + username = config.username + if not username: + raise Exception("SSH connection failed: Username is required") + + ssh_client = paramiko.SSHClient() + ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + # Connect to SSH server - use SSH agent if available, otherwise password + try: + ssh_client.connect( + hostname=config.host, + port=config.port or 22, + username=username, + password=config.password, + timeout=10, + allow_agent=True, # 允许使用SSH代理 + look_for_keys=False # 禁用查找本地密钥文件 + ) + except paramiko.AuthenticationException: + raise Exception(f"SSH connection failed: Authentication failed for user {username} on {config.host}") + except paramiko.SSHException as ssh_error: + raise Exception(f"SSH connection failed: {str(ssh_error)}") + except Exception as e: + raise Exception(f"Connection failed: {str(e)}") + + # Create SFTP client and check folder exists + try: + sftp_client = ssh_client.open_sftp() + sftp_client.stat(config.folder_path) + except Exception as e: + raise Exception(f"Folder access failed: {str(e)}") + + return True + except Exception as e: + logger.error(f"Error checking folder: {e}") + # 重新抛出异常,以便上层能够捕获并传递详细的错误信息 + raise + finally: + if sftp_client: + sftp_client.close() + if ssh_client: + ssh_client.close() diff --git a/sync/local_folder_sync.py b/sync/local_folder_sync.py deleted file mode 100644 index 9261f89..0000000 --- a/sync/local_folder_sync.py +++ /dev/null @@ -1,291 +0,0 @@ -"""Local folder synchronization implementation""" -import os -import re -from typing import List, Dict, Any, Set -from datetime import datetime -from pathlib import Path -from loguru import logger -from config import BaseDataSourceConfig -from sync.base_sync import BaseSync -from rag.file_parser import FileParser - - -class LocalFolderSync(BaseSync): - """Handle synchronization between local folder and ChromaDB""" - - def __init__(self, config: BaseDataSourceConfig): - """ - Initialize local folder sync with configuration - - Args: - config: Local folder configuration - """ - super().__init__(config) - self.file_parser = FileParser() - self._ssh_client = None - self._sftp_client = None - - def fetch_all_documents(self) -> List[Dict[str, Any]]: - """ - Fetch all documents from the local folder - - Returns: - List of documents - """ - return self._fetch_documents() - - def fetch_new_documents(self, last_sync_time=None, synced_doc_ids=None) -> List[Dict[str, Any]]: - """ - Fetch new/updated documents from the local folder since last sync time - - Args: - last_sync_time: Last synchronization time - synced_doc_ids: Set of document IDs that have already been synced - - Returns: - List of new/updated documents - """ - return self._fetch_documents(last_sync_time, synced_doc_ids) - - def get_synced_document_ids(self) -> Set[str]: - """ - Get IDs of all files in the local folder via SFTP - - Returns: - Set of file paths (as document IDs) - """ - self._connect() - try: - files = self._get_all_files() - return set(files) - finally: - self._disconnect() - - def _fetch_documents(self, last_sync_time=None, synced_doc_ids=None) -> List[Dict[str, Any]]: - """ - Internal method to fetch documents from local folder via SFTP - - Args: - last_sync_time: Last synchronization time (for incremental sync) - synced_doc_ids: Set of document IDs that have already been synced - - Returns: - List of documents - """ - documents = [] - self._connect() - try: - files = self._get_all_files() - - for file_path in files: - # Check if document has already been synced - if synced_doc_ids and file_path in synced_doc_ids: - # If file is already synced, check if it's been modified since last sync - if last_sync_time: - file_stat = self._sftp_client.stat(file_path) - file_mtime = datetime.fromtimestamp(file_stat.st_mtime) - # Skip if not modified since last sync - if file_mtime <= last_sync_time: - continue - # If file is not synced yet, always include it regardless of modification time - # This handles the case where files were added to the folder after last_sync_time but have older mtimes - - # Parse file content - try: - # 检查文件扩展名是否在支持的列表中 - file_ext = os.path.splitext(file_path)[1].lower() - if file_ext not in FileParser.SUPPORTED_EXTENSIONS: - logger.debug(f"Skipping unsupported file: {file_path}") - continue - - # 使用 SFTP 获取文件内容 - with self._sftp_client.open(file_path, 'rb') as f: - file_bytes = f.read() - - # 使用 BaseSync 中的通用方法解析文件内容 - content = self._parse_file_content(file_bytes, file_path) if file_bytes else f"[无法读取文件:{os.path.basename(file_path)}]" - - file_stat = self._sftp_client.stat(file_path) - document = { - 'id': file_path, - 'title': os.path.basename(file_path), - 'content': content, - 'file_path': file_path, - 'update_time': datetime.fromtimestamp(file_stat.st_mtime) - } - documents.append(document) - except Exception as e: - logger.error(f"Error processing file {file_path}: {e}") - finally: - self._disconnect() - - return documents - - def _connect(self): - """ - Connect to the host machine via SSH/SFTP - """ - import paramiko - - self._ssh_client = paramiko.SSHClient() - self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - # Connect to SSH server - # 确保username不为None,否则paramiko会报错 - username = self.config.username or '' - - # 建立SSH连接的参数 - ssh_params = { - 'hostname': self.config.host, - 'port': self.config.port or 22, - 'username': username, - 'password': self.config.password, - 'timeout': 10, - 'allow_agent': True, # 允许使用SSH代理 - 'look_for_keys': False # 禁用查找本地密钥文件 - } - - # 连接到SSH服务器 - self._ssh_client.connect(**ssh_params) - - # Create SFTP client - self._sftp_client = self._ssh_client.open_sftp() - - def _disconnect(self): - """ - Disconnect from the host machine - """ - if self._sftp_client: - self._sftp_client.close() - self._sftp_client = None - - if self._ssh_client: - self._ssh_client.close() - self._ssh_client = None - - def _get_all_files(self) -> List[str]: - """ - Get all files in the local folder via SFTP - - Returns: - List of file paths - """ - files = [] - # _get_files_recursive is called from _fetch_documents which handles connection - self._get_files_recursive(self.config.folder_path, files) - return files - - def _get_files_recursive(self, folder_path: str, files: List[str]): - """ - Recursively get all files in the local folder via SFTP - - Args: - folder_path: Current folder path - files: List to store found files - """ - try: - items = self._sftp_client.listdir_attr(folder_path) - - for item in items: - item_path = os.path.join(folder_path, item.filename) - - if item.filename not in ('.', '..'): - if item.st_mode & 0o040000: # Check if it's a directory - if self.config.recursive: - self._get_files_recursive(item_path, files) - else: - # Check if file should be ignored - if not self._should_ignore_file(item_path): - files.append(item_path) - except Exception as e: - logger.error(f"Error listing local folder {folder_path}: {e}") - - def _should_ignore_file(self, file_path: str) -> bool: - """ - Check if file should be ignored based on ignore patterns - - Args: - file_path: File path to check - - Returns: - True if file should be ignored, False otherwise - """ - if not hasattr(self.config, 'ignore_patterns') or not self.config.ignore_patterns: - return False - - # Get relative path from folder root - relative_path = os.path.relpath(file_path, self.config.folder_path) - - for pattern in self.config.ignore_patterns: - if self._match_pattern(relative_path, pattern): - return True - - return False - - def _match_pattern(self, path: str, pattern: str) -> bool: - """ - Match a path against a pattern (similar to .gitignore) - - Args: - path: Path to match - pattern: Pattern to match against - - Returns: - True if path matches pattern, False otherwise - """ - # Convert glob pattern to regex - regex_pattern = pattern - regex_pattern = regex_pattern.replace('.', r'\.') - regex_pattern = regex_pattern.replace('*', r'.*') - regex_pattern = regex_pattern.replace('?', r'.') - - # Handle directory patterns - if pattern.endswith('/'): - regex_pattern = f'^{regex_pattern}.*$' - else: - regex_pattern = f'^{regex_pattern}$' - - return bool(re.match(regex_pattern, path)) - - @staticmethod - def check_data_source_exists(config: BaseDataSourceConfig) -> bool: - """ - Check if the local folder exists and is accessible via SFTP - - Args: - config: Local folder configuration - - Returns: - True if local folder exists and is accessible, False otherwise - """ - import paramiko - - ssh_client = None - sftp_client = None - try: - ssh_client = paramiko.SSHClient() - ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - # Connect to SSH server - use SSH agent if available, otherwise password - username = config.username or '' - ssh_client.connect( - hostname=config.host, - port=config.port or 22, - username=username, - password=config.password, - timeout=10, - allow_agent=True, # 允许使用SSH代理 - look_for_keys=False # 禁用查找本地密钥文件 - ) - - sftp_client = ssh_client.open_sftp() - sftp_client.stat(config.folder_path) - return True - except Exception as e: - logger.error(f"Error checking local folder via SFTP: {e}") - return False - finally: - if sftp_client: - sftp_client.close() - if ssh_client: - ssh_client.close() diff --git a/sync/mysql_sync.py b/sync/mysql_sync.py index c916ddb..e2135a5 100644 --- a/sync/mysql_sync.py +++ b/sync/mysql_sync.py @@ -51,46 +51,9 @@ class MySQLSync(BaseSync): database=self.db_config.database ) - def fetch_all_documents(self) -> List[Dict[str, Any]]: + def fetch_all_documents(self, last_sync_time=None) -> List[Dict[str, Any]]: """ - Fetch all documents from the MySQL database - - Returns: - List of documents - """ - return self._fetch_documents() - - def fetch_new_documents(self, last_sync_time=None, synced_doc_ids=None) -> List[Dict[str, Any]]: - """ - Fetch new/updated documents from the MySQL database since last sync time - - Args: - last_sync_time: Last synchronization time - synced_doc_ids: Set of document IDs that have already been synced (ignored for MySQL) - - Returns: - List of new/updated documents - """ - return self._fetch_documents(last_sync_time) - - def get_synced_document_ids(self) -> Set[str]: - """ - Get IDs of all documents in the MySQL database - - Returns: - Set of document IDs - """ - cursor = self.connection.cursor(pymysql.cursors.DictCursor) - try: - query = f"SELECT {self.db_config.id_column} FROM {self.db_config.table_name}" - cursor.execute(query) - return {str(row[self.db_config.id_column]) for row in cursor.fetchall()} - finally: - cursor.close() - - def _fetch_documents(self, last_sync_time=None) -> List[Dict[str, Any]]: - """ - Internal method to fetch documents from MySQL + Fetch documents from the MySQL database Args: last_sync_time: Last synchronization time (for incremental sync) @@ -143,62 +106,123 @@ class MySQLSync(BaseSync): if file_content: row[self.db_config.content_column] = file_content + # Generate unique document ID + record_id = str(row[self.db_config.id_column]) + doc_id = self.generate_doc_id(record_id) + row['id'] = doc_id + + # Check if document has already been synced + if self.vector_store_manager and self.vector_store_manager.document_exists(doc_id): + # If document is already synced, check if it's been modified since last sync + if last_sync_time and self.db_config.updated_at_column and row.get(self.db_config.updated_at_column): + # Skip if not modified since last sync + if row[self.db_config.updated_at_column] <= last_sync_time: + continue + elif last_sync_time: + # No updated_at column, skip since we can't determine if it's been modified + continue + documents.append(row) return documents finally: cursor.close() - def _load_file_content(self, file_path: str) -> Optional[str]: + def fetch_new_documents(self, last_sync_time=None) -> List[Dict[str, Any]]: """ - Load file content using FileParser class + Fetch new/updated documents from the MySQL database since last sync time Args: - file_path: Path to the file + last_sync_time: Last synchronization time Returns: - File content if successfully loaded, None otherwise + List of new/updated documents """ - try: - if not self.db_config.file_source_type: - logger.debug(f"No file source type configured, skipping file loading for {file_path}") - return None - - # 获取文件扩展名 - import os - ext = os.path.splitext(file_path)[1].lower() - - # 检查文件是否被支持 - if ext not in FileParser.SUPPORTED_EXTENSIONS: - logger.debug(f"Unsupported file type {ext} for {file_path}, skipping...") - return None - - # 准备文件源配置 - source_config = { - 'file_system_base_path': self.db_config.file_system_base_path, - 'scp_host': self.db_config.scp_host, - 'scp_port': self.db_config.scp_port, - 'scp_username': self.db_config.scp_username, - 'scp_password': self.db_config.scp_password, - 'scp_key_path': self.db_config.scp_key_path - } - - # 使用 BaseSync 中的通用方法获取文件字节 - file_bytes = self._get_file_bytes(file_path, self.db_config.file_source_type, source_config) - - if file_bytes: - # 对于 SCP 文件源,传递 SCP 主机地址 - host = None - if self.db_config.file_source_type == 'scp' and self.db_config.scp_host: - host = self.db_config.scp_host - # 使用 BaseSync 中的通用方法解析文件内容 - return self._parse_file_content(file_bytes, file_path, host=host) - - except Exception as e: - logger.error(f"Error loading file content for {file_path}: {e}") - return None + # Call fetch_all_documents which now handles document existence checks + return self.fetch_all_documents(last_sync_time) + + def get_synced_document_ids(self) -> Set[str]: + """ + Get IDs of all documents in the MySQL database - return None + Returns: + Set of document IDs + """ + cursor = self.connection.cursor(pymysql.cursors.DictCursor) + try: + query = f"SELECT {self.db_config.id_column} FROM {self.db_config.table_name}" + cursor.execute(query) + return {str(row[self.db_config.id_column]) for row in cursor.fetchall()} + finally: + cursor.close() + + def generate_doc_id(self, record_id: str) -> str: + """ + Generate a unique document ID for MySQL records + + Args: + record_id: ID of the record in the database + + Returns: + Unique document ID based on database, table, and record ID + """ + # 为 MySQL 记录生成唯一的文档 ID + return f"{self.db_config.database}_{self.db_config.table_name}_{record_id}" + + def doc_to_llamaindex_doc(self, doc: Dict) -> 'Document': + """ + Convert MySQL document to LlamaIndex Document + + Args: + doc: MySQL document dictionary + + Returns: + LlamaIndex Document object + """ + from llama_index.core import Document + + # 处理多个 content 列(支持合并多个列的内容) + if self.db_config: + # 使用配置的多个 content 列 + content_columns = self.db_config.content_columns + content_separator = self.db_config.content_separator + else: + # 向后兼容:使用单个 content_column + content_columns = ["content"] # Default to "content" column + content_separator = "\n" + + # 合并所有 content 列的内容 + content_parts = [] + for col in content_columns: + col_value = doc.get(col, "") + if col_value: + content_parts.append(str(col_value)) + + # 用指定的分隔符连接多个列的内容 + content = content_separator.join(content_parts) if content_parts else "" + + title = doc.get('title', "") + doc_id = doc.get('id', "") + + # Build metadata + metadata = { + "doc_id": doc_id, + "source": "mysql", + "database": self.db_config.database, + "table": self.db_config.table_name + } + + if title: + metadata["title"] = title + + # Create Document + return Document( + text=content, + id_=doc_id, + metadata=metadata + ) + + @staticmethod def check_data_source_exists(config: DatabaseConfig) -> bool: diff --git a/sync/remote_folder_sync.py b/sync/remote_folder_sync.py deleted file mode 100644 index 396a047..0000000 --- a/sync/remote_folder_sync.py +++ /dev/null @@ -1,286 +0,0 @@ -"""Remote folder synchronization implementation""" -import os -import re -from typing import List, Dict, Any, Set -from datetime import datetime -from pathlib import Path -from loguru import logger -from config import BaseDataSourceConfig -from sync.base_sync import BaseSync -from rag.file_parser import FileParser - - -class RemoteFolderSync(BaseSync): - """Handle synchronization between remote folder (via SCP) and ChromaDB""" - - def __init__(self, config: BaseDataSourceConfig): - """ - Initialize remote folder sync with configuration - - Args: - config: Remote folder configuration - """ - super().__init__(config) - self.file_parser = FileParser() - self._ssh_client = None - self._sftp_client = None - - def fetch_all_documents(self) -> List[Dict[str, Any]]: - """ - Fetch all documents from the remote folder - - Returns: - List of documents - """ - return self._fetch_documents() - - def fetch_new_documents(self, last_sync_time=None, synced_doc_ids=None) -> List[Dict[str, Any]]: - """ - Fetch new/updated documents from the remote folder since last sync time - - Args: - last_sync_time: Last synchronization time - synced_doc_ids: Set of document IDs that have already been synced - - Returns: - List of new/updated documents - """ - return self._fetch_documents(last_sync_time, synced_doc_ids) - - def get_synced_document_ids(self) -> Set[str]: - """ - Get IDs of all files in the remote folder - - Returns: - Set of file paths (as document IDs) - """ - files = self._get_all_files() - return {str(file) for file in files} - - def _fetch_documents(self, last_sync_time=None, synced_doc_ids=None) -> List[Dict[str, Any]]: - """ - Internal method to fetch documents from remote folder via SFTP - - Args: - last_sync_time: Last synchronization time (for incremental sync) - synced_doc_ids: Set of document IDs that have already been synced - - Returns: - List of documents - """ - documents = [] - self._connect() - try: - files = self._get_all_files() - - for file_path in files: - # Check if document has already been synced - if synced_doc_ids and file_path in synced_doc_ids: - # If file is already synced, check if it's been modified since last sync - if last_sync_time: - file_stat = self._sftp_client.stat(file_path) - file_mtime = datetime.fromtimestamp(file_stat.st_mtime) - # Skip if not modified since last sync - if file_mtime <= last_sync_time: - continue - # If file is not synced yet, always include it regardless of modification time - # This handles the case where files were added to the folder after last_sync_time but have older mtimes - - # Parse file content - try: - # 使用 BaseSync 中的通用方法获取文件字节 - # 对于远程文件,我们需要先通过 SFTP 获取文件内容 - with self._sftp_client.open(file_path, 'rb') as f: - file_bytes = f.read() - - # 检查文件扩展名是否在支持的列表中 - if Path(file_path).suffix.lower() not in FileParser.SUPPORTED_EXTENSIONS: - logger.debug(f"Skipping unsupported file: {file_path}") - continue - - # 使用 BaseSync 中的通用方法解析文件内容,传递远程主机地址 - content = self._parse_file_content(file_bytes, file_path, host=self.config.host) if file_bytes else f"[无法读取文件:{Path(file_path).name}]" - - document = { - 'id': str(file_path), - 'title': Path(file_path).name, - 'content': content, - 'file_path': str(file_path), - 'update_time': datetime.fromtimestamp(self._sftp_client.stat(file_path).st_mtime) - } - documents.append(document) - except Exception as e: - logger.error(f"Error processing file {file_path}: {e}") - finally: - self._disconnect() - - return documents - - def _get_all_files(self) -> List[str]: - """ - Get all files in the remote folder - - Returns: - List of file paths - """ - files = [] - self._get_files_recursive(self.config.folder_path, files) - return files - - def _get_files_recursive(self, folder_path: str, files: List[str]): - """ - Recursively get all files in the remote folder - - Args: - folder_path: Current folder path - files: List to store found files - """ - try: - items = self._sftp_client.listdir_attr(folder_path) - - for item in items: - item_path = os.path.join(folder_path, item.filename) - - if item.filename not in ('.', '..'): - if item.st_mode & 0o040000: # Check if it's a directory - if self.config.recursive: - self._get_files_recursive(item_path, files) - else: - # Check if file should be ignored - if not self._should_ignore_file(item_path): - files.append(item_path) - except Exception as e: - logger.error(f"Error listing remote folder {folder_path}: {e}") - - def _should_ignore_file(self, file_path: str) -> bool: - """ - Check if file should be ignored based on ignore patterns - - Args: - file_path: File path to check - - Returns: - True if file should be ignored, False otherwise - """ - if not hasattr(self.config, 'ignore_patterns') or not self.config.ignore_patterns: - return False - - # Get relative path from folder root - relative_path = os.path.relpath(file_path, self.config.folder_path) - - for pattern in self.config.ignore_patterns: - if self._match_pattern(relative_path, pattern): - return True - - return False - - def _match_pattern(self, path: str, pattern: str) -> bool: - """ - Match a path against a pattern (similar to .gitignore) - - Args: - path: Path to match - pattern: Pattern to match against - - Returns: - True if path matches pattern, False otherwise - """ - # Convert glob pattern to regex - regex_pattern = pattern - regex_pattern = regex_pattern.replace('.', r'\.') - regex_pattern = regex_pattern.replace('*', r'.*') - regex_pattern = regex_pattern.replace('?', r'.') - - # Handle directory patterns - if pattern.endswith('/'): - regex_pattern = f'^{regex_pattern}.*$' - else: - regex_pattern = f'^{regex_pattern}$' - - return bool(re.match(regex_pattern, path)) - - def _connect(self): - """ - Connect to the remote server via SSH/SFTP - """ - import paramiko - - self._ssh_client = paramiko.SSHClient() - self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - # Connect to SSH server - # 确保username不为None,否则paramiko会报错 - username = self.config.username or '' - - # 使用密码认证或SSH代理认证 - self._ssh_client.connect( - hostname=self.config.host, - port=self.config.port or 22, - username=username, - password=self.config.password, - timeout=10, - allow_agent=True, # 允许使用SSH代理 - look_for_keys=False # 禁用查找本地密钥文件 - ) - - # Create SFTP client - self._sftp_client = self._ssh_client.open_sftp() - - def _disconnect(self): - """ - Disconnect from the remote server - """ - if self._sftp_client: - self._sftp_client.close() - self._sftp_client = None - - if self._ssh_client: - self._ssh_client.close() - self._ssh_client = None - - @staticmethod - def check_data_source_exists(config: BaseDataSourceConfig) -> bool: - """ - Check if the remote folder exists and is accessible - - Args: - config: Remote folder configuration - - Returns: - True if remote folder exists and is accessible, False otherwise - """ - import paramiko - - ssh_client = None - sftp_client = None - - try: - ssh_client = paramiko.SSHClient() - ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - - # Connect to SSH server - use SSH agent if available, otherwise password - username = config.username or '' - ssh_client.connect( - hostname=config.host, - port=config.port or 22, - username=username, - password=config.password, - timeout=10, - allow_agent=True, # 允许使用SSH代理 - look_for_keys=False # 禁用查找本地密钥文件 - ) - - # Create SFTP client and check folder exists - sftp_client = ssh_client.open_sftp() - sftp_client.stat(config.folder_path) - - return True - except Exception as e: - logger.error(f"Error checking remote folder: {e}") - return False - finally: - if sftp_client: - sftp_client.close() - if ssh_client: - ssh_client.close() - diff --git a/sync_service.py b/sync_service.py index efacb65..5cb55ed 100644 --- a/sync_service.py +++ b/sync_service.py @@ -3,11 +3,12 @@ Background service for syncing data from various sources to ChromaDB """ import asyncio from datetime import datetime -from typing import Set, List, Dict, Any +from typing import Dict from loguru import logger from config import settings, BaseDataSourceConfig from sync.base_sync import BaseSync, get_sync_class -from rag import VectorStoreManager, DocumentProcessor +from rag import VectorStoreManager +from db_utils import get_data_source_update_at, update_data_source_update_at class SyncService: @@ -19,11 +20,17 @@ class SyncService: # Check if the data source exists before proceeding logger.info(f"Checking if data source exists: {self.source_name}") - all_exist, missing_sources = BaseSync.check_data_sources_exist([self.source_config]) - if not all_exist: + try: + # 直接调用具体同步类的 check_data_source_exists 方法,以便捕获详细的错误信息 + sync_class = get_sync_class(self.source_config.type) + sync_class.check_data_source_exists(self.source_config) + + logger.info(f"✓ Data source {self.source_name} exists") + except Exception as e: error_msg = ( f"Error: Data source {self.source_name} does not exist or cannot be accessed\n" + f"Details: {str(e)}\n" f"Please check:\n" f" 1. Data source exists and is accessible\n" f" 2. Connection details (host, port, credentials) are correct\n" @@ -33,10 +40,7 @@ class SyncService: logger.error(error_msg) raise RuntimeError(error_msg) - logger.info(f"✓ Data source {self.source_name} exists") - self.vector_store_manager = VectorStoreManager() - self.document_processor = DocumentProcessor() self._running = False self._sync_in_progress = False # Flag to prevent concurrent syncs self._auto_sync_task = None # Reference to auto sync task to prevent multiple instances @@ -47,11 +51,19 @@ class SyncService: sync_class = get_sync_class(self.source_config.type) # Create a new syncer instance for this data source - self.syncer = sync_class(self.source_config) + self.syncer = sync_class(self.source_config, vector_store_manager=self.vector_store_manager) # Initialize sync tracking data self.last_sync_time = None - self.synced_doc_ids = set() + + # Read last sync time from data_sources table if available + try: + update_at = get_data_source_update_at(self.source_name) + if update_at: + self.last_sync_time = update_at + logger.info(f"Initialized last_sync_time from data_sources: {self.last_sync_time}") + except Exception as e: + logger.warning(f"Error reading last_sync_time from data_sources: {e}") logger.info(f"Initialized {self.source_config.type} syncer for {self.source_name}") except Exception as e: @@ -82,10 +94,7 @@ class SyncService: def sync_work(): """Synchronous work that runs in thread pool""" - # Get existing document IDs from ChromaDB to avoid re-processing - existing_doc_ids = set() - if not force: - existing_doc_ids = self.vector_store_manager.get_existing_doc_ids() + # Will check document existence individually using document_exists() method all_chunked_docs = [] # 存储所有分块后的文档 total_docs = 0 @@ -103,6 +112,7 @@ class SyncService: documents = self.syncer.fetch_all_documents() if not documents: + self.last_sync_time = datetime.now() return all_chunked_docs, total_docs, skipped_docs_count # Database-specific processing for content columns @@ -136,16 +146,13 @@ class SyncService: doc_id = str(doc.get(id_column, "")) # 生成唯一文档标识符(数据库标识名称_表名_文档ID){db_source}_{table_name}_{id} unique_doc_id = f"{self.source_config.name}_{self.source_config.table_name}_{doc_id}" - if unique_doc_id not in existing_doc_ids: + if not self.vector_store_manager.document_exists(unique_doc_id): new_documents.append(doc) else: db_skipped_count += 1 skipped_docs_count += 1 if not new_documents: - self.synced_doc_ids = { - str(doc.get(id_column)) for doc in documents - } self.last_sync_time = datetime.now() return all_chunked_docs, total_docs, skipped_docs_count documents = new_documents @@ -153,17 +160,13 @@ class SyncService: self.vector_store_manager.delete_documents_by_source(source_identifier) # Process and chunk documents - processed_docs = self.document_processor.process_documents(documents, self.source_config) - chunked_docs = self.document_processor.chunk_documents(processed_docs) + processed_docs = self.syncer.process_documents(documents) + chunked_docs = self.syncer.chunk_documents(processed_docs) all_chunked_docs.extend(chunked_docs) - # Update synced document IDs - id_column = self.source_config.id_column - self.synced_doc_ids = { - str(doc.get(id_column)) for doc in documents - } - else: - # Non-database sources (local/remote folder) + # Update last sync time + self.last_sync_time = datetime.now() + elif self.source_config.type == "folder": if not force: new_documents = [] for doc in documents: @@ -172,31 +175,35 @@ class SyncService: logger.info(f"Sync interrupted during document filtering: {self.source_name}") return all_chunked_docs, total_docs, skipped_docs_count # 返回结果,不退出程序 - doc_id = doc.get('id', str(doc.get('file_path', ''))) - if doc_id not in existing_doc_ids: + doc_id = doc.get('id') + if not self.vector_store_manager.document_exists(doc_id): new_documents.append(doc) else: skipped_docs_count += 1 if not new_documents: - self.synced_doc_ids = { - doc.get('id', str(doc.get('file_path', ''))) for doc in documents - } self.last_sync_time = datetime.now() return all_chunked_docs, total_docs, skipped_docs_count documents = new_documents # Process and chunk documents - processed_docs = self.document_processor.process_documents(documents, self.source_config) - chunked_docs = self.document_processor.chunk_documents(processed_docs) + processed_docs = self.syncer.process_documents(documents) + chunked_docs = self.syncer.chunk_documents(processed_docs) all_chunked_docs.extend(chunked_docs) - # Update synced document IDs - self.synced_doc_ids = { - doc.get('id', str(doc.get('file_path', ''))) for doc in documents - } + # Update last sync time + self.last_sync_time = datetime.now() + else: + raise Exception(f"不支持的数据类型") self.last_sync_time = datetime.now() + + # Update update_at in data_sources table + try: + update_data_source_update_at(self.source_name, self.last_sync_time) + except Exception as e: + logger.warning(f"Error updating update_at in data_sources: {e}") + total_docs += len(documents) except Exception as e: @@ -269,29 +276,27 @@ class SyncService: logger.info(f"Incremental sync interrupted: {self.source_name}") return all_chunked_docs, total_docs # 返回结果,不退出程序 - # Fetch new documents, passing synced_doc_ids to determine what needs syncing - new_documents = self.syncer.fetch_new_documents(self.last_sync_time, self.synced_doc_ids) + # Fetch new documents + new_documents = self.syncer.fetch_new_documents(self.last_sync_time) if not new_documents: logger.debug(f"No new documents in data source: {self.source_name}") return all_chunked_docs, total_docs # Process and chunk documents - processed_docs = self.document_processor.process_documents(new_documents, self.source_config) - chunked_docs = self.document_processor.chunk_documents(processed_docs) + processed_docs = self.syncer.process_documents(new_documents) + chunked_docs = self.syncer.chunk_documents(processed_docs) all_chunked_docs.extend(chunked_docs) - # Update synced document IDs for this data source - if self.source_config.type == 'database': - id_column = self.source_config.id_column - new_doc_ids = {str(doc.get(id_column)) for doc in new_documents} - self.synced_doc_ids.update(new_doc_ids) - else: - # Use file path or id as document ID for folder sources - new_doc_ids = {doc.get('id', str(doc.get('file_path', ''))) for doc in new_documents} - self.synced_doc_ids.update(new_doc_ids) - + # Update last sync time self.last_sync_time = datetime.now() + + # Update update_at in data_sources table + try: + update_data_source_update_at(self.source_name, self.last_sync_time) + except Exception as e: + logger.warning(f"Error updating update_at in data_sources: {e}") + total_docs += len(new_documents) logger.info(f"Incremental sync: {len(chunked_docs)} chunks from {len(new_documents)} documents in {self.source_config.type}: {self.source_name}") @@ -388,23 +393,9 @@ class SyncService: logger.info(f"Auto sync service with recovery stopped for {self.source_name}") self._running = False - async def start_auto_sync(self, skip_initial_sync: bool = False): - """ - Start automatic periodic sync in background for this data source. - This method runs continuously until stop_auto_sync() is called. - For production use, prefer start_auto_sync_with_recovery() which includes error recovery. - - Args: - skip_initial_sync: If True, skip the initial sync_all() call. - Use this when initial sync is already done elsewhere. - """ - logger.info(f"Starting auto sync for data source: {self.source_name}") - await self._run_auto_sync_loop(skip_initial_sync) - async def _run_auto_sync_loop(self, skip_initial_sync: bool = False): """ Internal method that runs the auto sync loop for this data source. - This is separated so it can be called by both start_auto_sync and start_auto_sync_with_recovery. Args: skip_initial_sync: If True, skip the initial sync_all() call.