from typing import Optional, List, Dict, Any, Tuple, Set import json import os from app.module.dataset import DatasetManagementService from sqlalchemy import update, select from app.db.models import DatasetFiles from app.core.logging import get_logger from app.core.config import settings from app.core.exception import ErrorCodes, BusinessError from ..client import LabelStudioClient from ..schema import ( SyncDatasetResponse, DatasetMappingResponse, SyncAnnotationsResponse ) from ..service.mapping import DatasetMappingService logger = get_logger(__name__) class SyncService: """数据同步服务""" def __init__( self, dm_client: DatasetManagementService, ls_client: LabelStudioClient, mapping_service: DatasetMappingService ): self.dm_client = dm_client self.ls_client = ls_client self.mapping_service = mapping_service def _determine_data_type(self, file_type: str) -> str: """根据文件类型确定数据类型""" file_type_lower = file_type.lower() type_mapping = { 'image': ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'], 'audio': ['mp3', 'wav', 'flac', 'aac', 'ogg'], 'video': ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'], 'text': ['txt', 'doc', 'docx', 'pdf'], 'wsi': ['svs', 'tiff', 'ndpi', 'mrxs', 'sdpc'], 'ct': ['dcm', 'dicom', 'nii', 'nii.gz'] } for data_type, extensions in type_mapping.items(): if any(ext in file_type_lower for ext in extensions): return data_type return 'image' # 默认为图像类型 @staticmethod def _normalize_ls_result_for_compare(result_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Normalize LS result items for deterministic comparison.""" normalized: List[Dict[str, Any]] = [] for item in result_items or []: if not isinstance(item, dict): continue from_name = item.get("from_name") or item.get("fromName") or "" to_name = item.get("to_name") or item.get("toName") or "" type_name = item.get("type") or "" if not isinstance(from_name, str): from_name = str(from_name) if not isinstance(to_name, str): to_name = str(to_name) if not isinstance(type_name, str): type_name = str(type_name) value_obj: Dict[str, Any] = {} raw_value_obj = item.get("value") if isinstance(raw_value_obj, dict): value_obj = raw_value_obj else: raw_values_obj = item.get("values") if isinstance(raw_values_obj, dict): value_obj = raw_values_obj normalized_value: Dict[str, Any] = {} for value_key, value_content in value_obj.items(): key_str = str(value_key).strip().lower() if value_key is not None else "" if key_str: normalized_value[key_str] = value_content normalized.append( { "from_name": from_name.strip(), "to_name": to_name.strip(), "type": type_name.strip().lower(), "value": normalized_value, } ) normalized.sort( key=lambda item: ( item.get("from_name", ""), item.get("to_name", ""), item.get("type", ""), json.dumps(item.get("value", {}), sort_keys=True, ensure_ascii=False), ) ) return normalized def _ls_result_payload_equal( self, left: List[Dict[str, Any]], right: List[Dict[str, Any]], ) -> bool: return self._normalize_ls_result_for_compare(left) == self._normalize_ls_result_for_compare(right) def _build_task_data(self, file_info: Any, dataset_id: str) -> dict: """构建Label Studio任务数据""" data_type = self._determine_data_type(file_info.fileType) # 默认仍然走 Label Studio 本地文件 URL # 先替换文件路径前缀,构造 /data/local-files/?d=/... 形式 relative_path = file_info.filePath.removeprefix(settings.dm_file_path_prefix) ls_file_url = settings.label_studio_file_path_prefix + relative_path data_value: Any = ls_file_url # 对于纯文本文件(例如 .txt),支持直接把文件内容写入到 data.text, # 这样在 Label Studio 里会直接显示文本内容,而不是 URL。 if data_type == "text": try: _, ext = os.path.splitext(file_info.filePath) ext = ext.lower() # 目前只对 .txt 做内联,其他如 pdf/doc 仍然使用 URL if ext == ".txt": with open(file_info.filePath, "r", encoding="utf-8", errors="ignore") as f: content = f.read() if content: data_value = content except Exception as e: # 读取失败时退回到原来的 URL 形式,避免中断同步流程 logger.warning( "Failed to inline text content for file %s: %s", getattr(file_info, "filePath", ""), str(e), ) return { "data": { f"{data_type}": data_value, "file_path": file_info.filePath, "file_id": file_info.id, "original_name": file_info.originalName, "dataset_id": dataset_id, } } async def _create_tasks_with_fallback( self, project_id: str, tasks: List[dict] ) -> int: """批量创建任务,失败时回退到单个创建""" if not tasks: return 0 # 尝试批量创建 batch_result = await self.ls_client.create_tasks_batch(project_id, tasks) if batch_result: logger.debug(f"Successfully created {len(tasks)} tasks in batch") return len(tasks) # 批量失败,回退到单个创建 logger.warning(f"Batch creation failed, falling back to single creation") created_count = 0 for task_data in tasks: task_result = await self.ls_client.create_task( project_id, task_data["data"], task_data.get("meta") ) if task_result: created_count += 1 logger.debug(f"Successfully created {created_count}/{len(tasks)} tasks individually") return created_count async def get_existing_dm_file_mapping(self, project_id: str) -> Dict[str, int]: """ 获取Label Studio项目中已存在的DM文件ID到任务ID的映射 Args: project_id: Label Studio项目ID Returns: file_id到task_id的映射字典 """ try: page_size = getattr(settings, 'ls_task_page_size', 1000) result = await self.ls_client.get_project_tasks( project_id=project_id, page=None, page_size=page_size ) if not result: logger.warning(f"Failed to fetch tasks for project {project_id}") return {} all_tasks = result.get("tasks", []) logger.debug(f"Successfully fetched {len(all_tasks)} tasks") # 使用字典推导式构建映射 dm_file_to_task_mapping = { str(task.get('data', {}).get('file_id')): task.get('id') for task in all_tasks if task.get('data', {}).get('file_id') is not None } logger.debug(f"Found {len(dm_file_to_task_mapping)} existing task mappings") return dm_file_to_task_mapping except Exception as e: logger.error(f"Error while fetching existing tasks: {e}") return {} async def _fetch_dm_files_paginated( self, dataset_id: str, batch_size: int, existing_file_ids: Set[str], project_id: str, allowed_file_ids: Optional[Set[str]] = None, ) -> Tuple[Set[str], int]: """ 分页获取DM文件并创建新任务 Returns: (当前文件ID集合, 创建的任务数) """ current_file_ids = set() total_created = 0 page = 0 while True: files_response = await self.dm_client.get_dataset_files( dataset_id, page=page, size=batch_size, ) if not files_response or not files_response.content: logger.debug(f"No more files on page {page + 1}") break logger.debug(f"Processing page {page + 1}, {len(files_response.content)} files") # 筛选新文件并构建任务数据 new_tasks = [] for file_info in files_response.content: file_id = str(file_info.id) # 如果提供了允许的文件ID集合,则只同步这些文件 if allowed_file_ids is not None and file_id not in allowed_file_ids: continue current_file_ids.add(file_id) if file_id not in existing_file_ids: task_data = self._build_task_data(file_info, dataset_id) new_tasks.append(task_data) logger.debug(f"Page {page + 1}: {len(new_tasks)} new files, {len(files_response.content) - len(new_tasks)} existing") # 批量创建任务 if new_tasks: created = await self._create_tasks_with_fallback(project_id, new_tasks) total_created += created # 检查是否还有更多页面 if page >= files_response.totalPages - 1: break page += 1 return current_file_ids, total_created async def _delete_orphaned_tasks( self, existing_dm_file_mapping: Dict[str, int], current_file_ids: Set[str] ) -> int: """删除在DM中不存在的Label Studio任务""" # 使用集合操作找出需要删除的文件ID deleted_file_ids = set(existing_dm_file_mapping.keys()) - current_file_ids if not deleted_file_ids: logger.debug("No tasks to delete") return 0 tasks_to_delete = [existing_dm_file_mapping[fid] for fid in deleted_file_ids] logger.debug(f"Deleting {len(tasks_to_delete)} orphaned tasks") delete_result = await self.ls_client.delete_tasks_batch(tasks_to_delete) deleted_count = delete_result.get("successful", 0) logger.debug(f"Successfully deleted {deleted_count} tasks") return deleted_count async def sync_dataset_files( self, mapping_id: str, batch_size: int = 50 ) -> SyncDatasetResponse: """ 同步数据集文件到Label Studio (Legacy endpoint - 委托给sync_files) Args: mapping_id: 映射ID batch_size: 批处理大小 Returns: 同步结果响应 """ logger.debug(f"Start syncing dataset files by mapping: {mapping_id}") # 获取映射关系 mapping = await self.mapping_service.get_mapping_by_uuid(mapping_id) if not mapping: logger.error(f"Dataset mapping not found: {mapping_id}") return SyncDatasetResponse( id="", status="error", synced_files=0, total_files=0, message=f"Dataset mapping not found: {mapping_id}" ) try: # 委托给sync_files执行实际同步 result = await self.sync_files(mapping, batch_size) logger.info(f"Sync files completed: created={result['created']}, deleted={result['deleted']}, total={result['total']}") return SyncDatasetResponse( id=mapping.id, status="success", synced_files=result["created"], total_files=result["total"], message=f"Sync completed: created {result['created']} files, deleted {result['deleted']} tasks" ) except Exception as e: logger.error(f"Error while syncing dataset: {e}") return SyncDatasetResponse( id=mapping.id, status="error", synced_files=0, total_files=0, message=f"Sync failed: {str(e)}" ) async def sync_dataset( self, mapping_id: str, batch_size: int = 50, file_priority: int = 0, annotation_priority: int = 0 ) -> SyncDatasetResponse: """ 同步数据集文件和标注 Args: mapping_id: 映射ID batch_size: 批处理大小 file_priority: 文件同步优先级 (0: dataset优先, 1: annotation优先) annotation_priority: 标注同步优先级 (0: dataset优先, 1: annotation优先) Returns: 同步结果响应 """ logger.info(f"Start syncing dataset by mapping: {mapping_id}") # 检查映射是否存在 mapping = await self.mapping_service.get_mapping_by_uuid(mapping_id) if not mapping: logger.error(f"Dataset mapping not found: {mapping_id}") return SyncDatasetResponse( id="", status="error", synced_files=0, total_files=0, message=f"Dataset mapping not found: {mapping_id}" ) try: # 同步文件(不限制文件ID,完整同步映射对应数据集) file_result = await self.sync_files(mapping, batch_size) # TODO: 同步标注 # annotation_result = await self.sync_annotations(mapping, batch_size, annotation_priority) logger.info(f"Sync completed: created={file_result['created']}, deleted={file_result['deleted']}, total={file_result['total']}") return SyncDatasetResponse( id=mapping.id, status="success", synced_files=file_result["created"], total_files=file_result["total"], message=f"Sync completed: created {file_result['created']} files, deleted {file_result['deleted']} tasks" ) except Exception as e: logger.error(f"Error while syncing dataset: {e}") return SyncDatasetResponse( id=mapping.id, status="error", synced_files=0, total_files=0, message=f"Sync failed: {str(e)}" ) async def sync_files( self, mapping: DatasetMappingResponse, batch_size: int, allowed_file_ids: Optional[Set[str]] = None, override_dataset_id: Optional[str] = None, delete_orphans: bool = True, ) -> Dict[str, int]: """ 同步DM和Label Studio之间的文件 Args: mapping: 数据集映射信息 batch_size: 批处理大小 Returns: 同步统计信息: {"created": int, "deleted": int, "total": int} """ effective_dataset_id = override_dataset_id or mapping.dataset_id logger.debug( "Syncing files for dataset %s to project %s (mapping dataset_id=%s)", effective_dataset_id, mapping.labeling_project_id, mapping.dataset_id, ) # 获取DM数据集信息 dataset_info = await self.dm_client.get_dataset(effective_dataset_id) if not dataset_info: raise BusinessError(ErrorCodes.NOT_FOUND, data={"dataset_id": mapping.dataset_id}) total_files = dataset_info.fileCount logger.debug(f"Total files in DM dataset: {total_files}") # 获取Label Studio中已存在的文件映射 existing_dm_file_mapping = await self.get_existing_dm_file_mapping(mapping.labeling_project_id) existing_file_ids = set(existing_dm_file_mapping.keys()) logger.debug(f"{len(existing_file_ids)} tasks already exist in Label Studio") # 分页获取DM文件并创建新任务 current_file_ids, created_count = await self._fetch_dm_files_paginated( effective_dataset_id, batch_size, existing_file_ids, mapping.labeling_project_id, allowed_file_ids=allowed_file_ids, ) # 删除孤立任务:在多数据集、分批同步场景下可以选择关闭, # 避免后一次同步把前一次其他数据集的任务当成“孤儿”删除。 if delete_orphans: deleted_count = await self._delete_orphaned_tasks( existing_dm_file_mapping, current_file_ids, ) else: deleted_count = 0 logger.debug(f"File sync completed: total={total_files}, created={created_count}, deleted={deleted_count}") return { "created": created_count, "deleted": deleted_count, "total": total_files } async def sync_annotations( self, mapping: DatasetMappingResponse, batch_size: int, priority: int ) -> Dict[str, int]: """ 同步DM和Label Studio之间的标注 Args: mapping: 数据集映射信息 batch_size: 批处理大小 priority: 标注同步优先级 (0: dataset优先, 1: annotation优先) Returns: 同步统计信息: {"synced_to_dm": int, "synced_to_ls": int} """ logger.info(f"Syncing annotations for dataset {mapping.dataset_id} (priority={priority})") # TODO: 实现标注同步逻辑 # 1. 从DM获取标注结果 # 2. 从Label Studio获取标注结果 # 3. 根据优先级合并结果 # 4. 将差异写入DM和LS logger.info("Annotation sync not yet implemented") return { "synced_to_dm": 0, "synced_to_ls": 0 } def _simplify_annotation_result(self, annotation: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], str]: """ 将Label Studio标注结果简化为指定格式 Args: annotation: Label Studio原始标注数据 Returns: Tuple of (简化后的标注结果列表, 标注更新时间ISO字符串) """ simplified = [] # 获取result字段(包含实际的标注数据) results = annotation.get("result", []) # 获取标注的更新时间,优先使用updated_at,否则使用created_at updated_at = annotation.get("updated_at") or annotation.get("created_at", "") for result_item in results: result_type = result_item.get("type", "") if isinstance(result_type, str): result_type = result_type.strip().lower() else: result_type = str(result_type).strip().lower() if result_type is not None else "" raw_value = result_item.get("value", {}) normalized_values: Dict[str, Any] = {} if isinstance(raw_value, dict): for value_key, value_content in raw_value.items(): key_str = str(value_key).strip().lower() if value_key is not None else "" if key_str: normalized_values[key_str] = value_content if result_type: if result_type in normalized_values: normalized_values = {result_type: normalized_values[result_type]} elif len(normalized_values) == 1: only_value = next(iter(normalized_values.values())) normalized_values = {result_type: only_value} simplified_item = { "from_name": result_item.get("from_name", ""), "to_name": result_item.get("to_name", ""), "type": result_type, "values": normalized_values } simplified.append(simplified_item) return simplified, updated_at def _compare_timestamps(self, ts1: str, ts2: str) -> int: """ 比较两个ISO格式时间戳 Args: ts1: 第一个时间戳 ts2: 第二个时间戳 Returns: 1 如果 ts1 > ts2 -1 如果 ts1 < ts2 0 如果相等或无法比较 """ try: from dateutil import parser from datetime import timezone dt1 = parser.parse(ts1) dt2 = parser.parse(ts2) # Convert both to UTC timezone-aware if needed if dt1.tzinfo is None: dt1 = dt1.replace(tzinfo=timezone.utc) if dt2.tzinfo is None: dt2 = dt2.replace(tzinfo=timezone.utc) if dt1 > dt2: return 1 elif dt1 < dt2: return -1 else: return 0 except Exception as e: logger.warning(f"Failed to compare timestamps {ts1} and {ts2}: {e}") return 0 def _should_overwrite_dm(self, ls_updated_at: str, dm_tags_updated_at: Optional[str], overwrite: bool) -> bool: """ 判断是否应该用Label Studio的标注覆盖DeepData的标注 Args: ls_updated_at: Label Studio标注的更新时间 dm_tags_updated_at: DeepData中标注的更新时间(从tags_updated_at字段) overwrite: 是否允许覆盖 Returns: True 如果应该覆盖,False 如果不应该覆盖 """ # 如果不允许覆盖,直接返回False if not overwrite: return False # 如果DeepData没有标注时间戳,允许覆盖 if not dm_tags_updated_at: return True # 如果Label Studio的标注更新,允许覆盖 return self._compare_timestamps(ls_updated_at, dm_tags_updated_at) > 0 def _should_overwrite_ls(self, dm_tags_updated_at: Optional[str], ls_updated_at: str, overwrite_ls: bool) -> bool: """ 判断是否应该用DeepData的标注覆盖Label Studio的标注 Args: dm_tags_updated_at: DeepData中标注的更新时间(从tags_updated_at字段) ls_updated_at: Label Studio标注的更新时间 overwrite_ls: 是否允许覆盖Label Studio Returns: True 如果应该覆盖,False 如果不应该覆盖 """ # 如果不允许覆盖,直接返回False if not overwrite_ls: return False # 如果DeepData没有标注时间戳,不应该覆盖Label Studio if not dm_tags_updated_at: return False # 如果Label Studio没有标注,应该覆盖 if not ls_updated_at: return True # 如果DeepData的标注更新,允许覆盖 return self._compare_timestamps(dm_tags_updated_at, ls_updated_at) > 0 async def sync_annotations_from_ls_to_dm( self, mapping: DatasetMappingResponse, batch_size: int = 50, overwrite: bool = True, sync_files_first: bool = True, ) -> SyncAnnotationsResponse: """ 从Label Studio同步标注到数据集 Args: mapping: 数据集映射信息 batch_size: 批处理大小 overwrite: 是否允许覆盖DeepData中的标注(基于时间戳比较) Returns: 同步结果响应 """ logger.info(f"Syncing annotations from LS to DM: dataset={mapping.dataset_id}, project={mapping.labeling_project_id}") synced_count = 0 skipped_count = 0 failed_count = 0 conflicts_resolved = 0 try: # 先做文件差异同步,保证 LS 任务集合与 DM 文件集合一致 if sync_files_first: file_sync_result = await self.sync_files(mapping, batch_size) logger.info( "Pre-annotation file sync done (LS->DM): created=%s, deleted=%s, total=%s", file_sync_result.get("created", 0), file_sync_result.get("deleted", 0), file_sync_result.get("total", 0), ) # 获取Label Studio中的所有任务 ls_tasks_result = await self.ls_client.get_project_tasks( mapping.labeling_project_id, page=None ) if not ls_tasks_result: token_display = settings.label_studio_user_token[:10] + "..." if settings.label_studio_user_token else "None" error_msg = f"Failed to fetch tasks from Label Studio project {mapping.labeling_project_id}. Please check:\n" \ f"1. Label Studio is running at {settings.label_studio_base_url}\n" \ f"2. Project ID {mapping.labeling_project_id} exists\n" \ f"3. API token is valid: {token_display}" logger.error(error_msg) return SyncAnnotationsResponse( id=mapping.id, status="error", synced_to_dm=0, synced_to_ls=0, skipped=0, failed=0, conflicts_resolved=0, message=f"Failed to connect to Label Studio at {settings.label_studio_base_url}" ) all_tasks = ls_tasks_result.get("tasks", []) logger.info(f"Found {len(all_tasks)} tasks in Label Studio project") if len(all_tasks) == 0: logger.warning(f"No tasks found in Label Studio project {mapping.labeling_project_id}") return SyncAnnotationsResponse( id=mapping.id, status="success", synced_to_dm=0, synced_to_ls=0, skipped=0, failed=0, conflicts_resolved=0, message="No tasks found in Label Studio project" ) # 批量处理任务 for i in range(0, len(all_tasks), batch_size): batch_tasks = all_tasks[i:i + batch_size] logger.info(f"Processing batch {i // batch_size + 1}, {len(batch_tasks)} tasks") for task in batch_tasks: task_id = task.get("id") file_id = task.get("data", {}).get("file_id") if not file_id: logger.warning(f"Task {task_id} has no file_id, skipping") skipped_count += 1 continue # 获取任务的标注结果 annotations = await self.ls_client.get_task_annotations(task_id) if not annotations: logger.debug(f"No annotations for task {task_id}, skipping") skipped_count += 1 continue # 简化标注结果(取最新的标注) latest_annotation = max(annotations, key=lambda a: a.get("updated_at") or a.get("created_at", "")) simplified_annotations, ls_updated_at = self._simplify_annotation_result(latest_annotation) if not simplified_annotations: logger.debug(f"Task {task_id} has no valid annotation results") skipped_count += 1 continue # 更新数据库中的tags字段 try: # 检查文件是否存在以及是否已有标注 result = await self.dm_client.db.execute( select(DatasetFiles).where( DatasetFiles.id == file_id, DatasetFiles.dataset_id == mapping.dataset_id ) ) file_record = result.scalar_one_or_none() if not file_record: logger.warning(f"File {file_id} not found in dataset {mapping.dataset_id}") failed_count += 1 continue # 检查是否应该覆盖DeepData的标注(使用文件级别的tags_updated_at) dm_tags_updated_at: Optional[str] = None if file_record.tags_updated_at: # type: ignore dm_tags_updated_at = file_record.tags_updated_at.isoformat() # type: ignore if not self._should_overwrite_dm(ls_updated_at, dm_tags_updated_at, overwrite): logger.debug( "File %s: DeepData has newer or equal annotations, skipping (overwrite=%s, ls_updated_at=%s, dm_tags_updated_at=%s)", file_id, overwrite, ls_updated_at, dm_tags_updated_at, ) skipped_count += 1 continue # 如果存在冲突(两边都有标注且时间戳不同),记录为冲突解决 if file_record.tags and ls_updated_at: # type: ignore conflicts_resolved += 1 logger.debug(f"File {file_id}: Resolved conflict, Label Studio annotation is newer") # 更新tags字段和tags_updated_at from datetime import datetime tags_updated_datetime = datetime.fromisoformat(ls_updated_at.replace('Z', '+00:00')) await self.dm_client.db.execute( update(DatasetFiles) .where(DatasetFiles.id == file_id) .values( tags=simplified_annotations, tags_updated_at=tags_updated_datetime.replace(tzinfo=None) ) ) await self.dm_client.db.commit() synced_count += 1 logger.debug(f"Synced annotations for file {file_id}: {len(simplified_annotations)} results") except Exception as e: logger.error(f"Failed to update annotations for file {file_id}: {e}") failed_count += 1 await self.dm_client.db.rollback() logger.info(f"Annotation sync completed: synced={synced_count}, skipped={skipped_count}, failed={failed_count}, conflicts_resolved={conflicts_resolved}") status = "success" if failed_count == 0 else ("partial" if synced_count > 0 else "error") return SyncAnnotationsResponse( id=mapping.id, status=status, synced_to_dm=synced_count, synced_to_ls=0, skipped=skipped_count, failed=failed_count, conflicts_resolved=conflicts_resolved, message=f"Synced {synced_count} annotations from Label Studio to dataset. Skipped: {skipped_count}, Failed: {failed_count}, Conflicts resolved: {conflicts_resolved}" ) except Exception as e: logger.error(f"Error while syncing annotations from LS to DM: {e}") return SyncAnnotationsResponse( id=mapping.id, status="error", synced_to_dm=synced_count, synced_to_ls=0, skipped=skipped_count, failed=failed_count, conflicts_resolved=conflicts_resolved, message=f"Sync failed: {str(e)}" ) async def sync_annotations_from_dm_to_ls( self, mapping: DatasetMappingResponse, batch_size: int = 50, overwrite_ls: bool = True, sync_files_first: bool = True, ) -> SyncAnnotationsResponse: """ 从DeepData数据集同步标注到Label Studio Args: mapping: 数据集映射信息 batch_size: 批处理大小 overwrite_ls: 是否允许覆盖Label Studio中的标注(基于时间戳比较) Returns: 同步结果响应 """ logger.info(f"Syncing annotations from DM to LS: dataset={mapping.dataset_id}, project={mapping.labeling_project_id}") synced_count = 0 skipped_count = 0 failed_count = 0 conflicts_resolved = 0 try: # 先做文件差异同步,保证 LS 任务集合与 DM 文件集合一致 if sync_files_first: file_sync_result = await self.sync_files(mapping, batch_size) logger.info( "Pre-annotation file sync done (DM->LS): created=%s, deleted=%s, total=%s", file_sync_result.get("created", 0), file_sync_result.get("deleted", 0), file_sync_result.get("total", 0), ) # 获取Label Studio中的文件ID到任务ID的映射 dm_file_to_task_mapping = await self.get_existing_dm_file_mapping(mapping.labeling_project_id) if not dm_file_to_task_mapping: logger.warning(f"No task mapping found for project {mapping.labeling_project_id}") return SyncAnnotationsResponse( id=mapping.id, status="error", synced_to_dm=0, synced_to_ls=0, skipped=0, failed=0, conflicts_resolved=0, message="No tasks found in Label Studio project" ) logger.info(f"Found {len(dm_file_to_task_mapping)} task mappings") # 分页获取DeepData中的文件 page = 0 processed_count = 0 while True: files_response = await self.dm_client.get_dataset_files( mapping.dataset_id, page=page, size=batch_size, ) if not files_response or not files_response.content: logger.info(f"No more files on page {page + 1}") break logger.info(f"Processing page {page + 1}, {len(files_response.content)} files") for file_info in files_response.content: file_id = str(file_info.id) processed_count += 1 # 检查该文件是否在Label Studio中有对应的任务 task_id = dm_file_to_task_mapping.get(file_id) if not task_id: logger.debug(f"File {file_id} has no corresponding task in Label Studio, skipping") skipped_count += 1 continue # 获取DeepData中的标注 dm_tags: List[Dict[str, Any]] = file_info.tags if file_info.tags else [] # type: ignore if not dm_tags: logger.debug(f"File {file_id} has no annotations in DeepData, skipping") skipped_count += 1 continue # 获取DeepData中标注的更新时间 dm_tags_updated_at: Optional[str] = None if file_info.tags_updated_at: # type: ignore dm_tags_updated_at = file_info.tags_updated_at.isoformat() # type: ignore try: # 获取Label Studio中该任务的现有标注 ls_annotations = await self.ls_client.get_task_annotations(task_id) # 获取Label Studio标注的更新时间 ls_updated_at = "" if ls_annotations: latest_ls_annotation = max( ls_annotations, key=lambda a: a.get("updated_at") or a.get("created_at", "") ) ls_updated_at = latest_ls_annotation.get("updated_at") or latest_ls_annotation.get("created_at", "") # 检查是否应该覆盖Label Studio的标注 should_overwrite_ls = self._should_overwrite_ls(dm_tags_updated_at, ls_updated_at, overwrite_ls) if not should_overwrite_ls: forced_on_equal_ts = False if ( overwrite_ls and dm_tags_updated_at and ls_updated_at and self._compare_timestamps(dm_tags_updated_at, ls_updated_at) == 0 and ls_annotations ): latest_ls_result_payload = latest_ls_annotation.get("result", []) if latest_ls_annotation else [] if not self._ls_result_payload_equal(ls_result, latest_ls_result_payload): forced_on_equal_ts = True logger.info( "Task %s: timestamps are equal but payload differs, force syncing DM -> LS", task_id, ) if not forced_on_equal_ts: logger.debug( "Task %s: Label Studio has newer or equal annotations, skipping (overwrite_ls=%s, dm_tags_updated_at=%s, ls_updated_at=%s)", task_id, overwrite_ls, dm_tags_updated_at, ls_updated_at, ) skipped_count += 1 continue # 如果存在冲突,记录为冲突解决 if ls_annotations and dm_tags: conflicts_resolved += 1 logger.debug(f"Task {task_id}: Resolved conflict, DeepData annotation is newer") # 将DeepData的标注转换为Label Studio格式(兼容 value/values 与大小写差异) ls_result = [] for tag in dm_tags: if not isinstance(tag, dict): continue from_name = tag.get("from_name") or tag.get("fromName") or "" to_name = tag.get("to_name") or tag.get("toName") or "" if not isinstance(from_name, str): from_name = str(from_name) if not isinstance(to_name, str): to_name = str(to_name) from_name = from_name.strip() to_name = to_name.strip() if not from_name or not to_name: logger.warning( "Skip invalid DM tag for LS sync due to missing from_name/to_name: %s", tag, ) continue raw_type = tag.get("type", "") if not isinstance(raw_type, str): raw_type = str(raw_type) if raw_type is not None else "" normalized_type = raw_type.strip().lower() raw_values = tag.get("values") if not isinstance(raw_values, dict): raw_values = tag.get("value") normalized_value: Dict[str, Any] = {} if isinstance(raw_values, dict): for value_key, value_content in raw_values.items(): key_str = str(value_key).strip().lower() if value_key is not None else "" if key_str: normalized_value[key_str] = value_content if not normalized_type and len(normalized_value) == 1: normalized_type = next(iter(normalized_value.keys())) if normalized_type: if normalized_type in normalized_value: normalized_value = {normalized_type: normalized_value[normalized_type]} elif len(normalized_value) == 1: only_value = next(iter(normalized_value.values())) normalized_value = {normalized_type: only_value} if not normalized_type or not normalized_value: logger.warning( "Skip invalid DM tag for LS sync: from_name=%s, to_name=%s, type=%s, values=%s", from_name, to_name, raw_type, raw_values, ) continue ls_result_item = { "from_name": from_name, "to_name": to_name, "type": normalized_type, "value": normalized_value, } ls_result.append(ls_result_item) if not ls_result: logger.warning(f"Task {task_id}: no valid DM annotations after normalization, skipping") skipped_count += 1 continue # 如果Label Studio已有标注,更新它;否则创建新标注 if ls_annotations: # 更新最新的标注 latest_annotation_id = latest_ls_annotation.get("id") if not latest_annotation_id: logger.error(f"Task {task_id} has no annotation ID") failed_count += 1 continue update_result = await self.ls_client.update_annotation( int(latest_annotation_id), ls_result ) if update_result: synced_count += 1 logger.debug(f"Updated annotation for task {task_id}") else: failed_count += 1 logger.error(f"Failed to update annotation for task {task_id}") else: # 创建新标注 create_result = await self.ls_client.create_annotation( task_id, ls_result ) if create_result: synced_count += 1 logger.debug(f"Created annotation for task {task_id}") else: failed_count += 1 logger.error(f"Failed to create annotation for task {task_id}") except Exception as e: logger.error(f"Failed to sync annotations for file {file_id} (task {task_id}): {e}") failed_count += 1 # 检查是否还有更多页面 if page >= files_response.totalPages - 1: break page += 1 logger.info(f"Annotation sync completed: synced={synced_count}, skipped={skipped_count}, failed={failed_count}, conflicts_resolved={conflicts_resolved}") status = "success" if failed_count == 0 else ("partial" if synced_count > 0 else "error") return SyncAnnotationsResponse( id=mapping.id, status=status, synced_to_dm=0, synced_to_ls=synced_count, skipped=skipped_count, failed=failed_count, conflicts_resolved=conflicts_resolved, message=f"Synced {synced_count} annotations from DeepData to Label Studio. Skipped: {skipped_count}, Failed: {failed_count}, Conflicts resolved: {conflicts_resolved}" ) except Exception as e: logger.error(f"Error while syncing annotations from DM to LS: {e}") return SyncAnnotationsResponse( id=mapping.id, status="error", synced_to_dm=0, synced_to_ls=synced_count, skipped=skipped_count, failed=failed_count, conflicts_resolved=conflicts_resolved, message=f"Sync failed: {str(e)}" ) async def sync_annotations_bidirectional( self, mapping: DatasetMappingResponse, batch_size: int = 50, overwrite: bool = True, overwrite_ls: bool = True, sync_files_first: bool = True, ) -> SyncAnnotationsResponse: """ 双向同步标注结果 Args: mapping: 数据集映射信息 batch_size: 批处理大小 overwrite: 是否允许覆盖DeepData中的标注 overwrite_ls: 是否允许覆盖Label Studio中的标注 Returns: 同步结果响应 """ logger.info(f"Bidirectional annotation sync: dataset={mapping.dataset_id}, project={mapping.labeling_project_id}") try: # 先做文件差异同步,保证双向标注同步处理的是同一批文件 if sync_files_first: file_sync_result = await self.sync_files(mapping, batch_size) logger.info( "Pre-annotation file sync done (bidirectional): created=%s, deleted=%s, total=%s", file_sync_result.get("created", 0), file_sync_result.get("deleted", 0), file_sync_result.get("total", 0), ) # 先从Label Studio同步到DeepData ls_to_dm_result = await self.sync_annotations_from_ls_to_dm( mapping, batch_size, overwrite, sync_files_first=False, ) # 再从DeepData同步到Label Studio dm_to_ls_result = await self.sync_annotations_from_dm_to_ls( mapping, batch_size, overwrite_ls, sync_files_first=False, ) # 合并结果 total_synced_to_dm = ls_to_dm_result.synced_to_dm total_synced_to_ls = dm_to_ls_result.synced_to_ls total_skipped = ls_to_dm_result.skipped + dm_to_ls_result.skipped total_failed = ls_to_dm_result.failed + dm_to_ls_result.failed total_conflicts = ls_to_dm_result.conflicts_resolved + dm_to_ls_result.conflicts_resolved # 判断状态 if ls_to_dm_result.status == "error" and dm_to_ls_result.status == "error": status = "error" elif total_failed > 0: status = "partial" else: status = "success" logger.info(f"Bidirectional sync completed: to_dm={total_synced_to_dm}, to_ls={total_synced_to_ls}, skipped={total_skipped}, failed={total_failed}, conflicts={total_conflicts}") return SyncAnnotationsResponse( id=mapping.id, status=status, synced_to_dm=total_synced_to_dm, synced_to_ls=total_synced_to_ls, skipped=total_skipped, failed=total_failed, conflicts_resolved=total_conflicts, message=f"Bidirectional sync completed: {total_synced_to_dm} to DeepData, {total_synced_to_ls} to Label Studio. Skipped: {total_skipped}, Failed: {total_failed}, Conflicts resolved: {total_conflicts}" ) except Exception as e: logger.error(f"Error during bidirectional sync: {e}") return SyncAnnotationsResponse( id=mapping.id, status="error", synced_to_dm=0, synced_to_ls=0, skipped=0, failed=0, conflicts_resolved=0, message=f"Bidirectional sync failed: {str(e)}" ) async def get_sync_status( self, dataset_id: str ) -> Optional[Dict[str, Any]]: """获取同步状态""" mapping = await self.mapping_service.get_mapping_by_source_uuid(dataset_id) if not mapping: return None # 获取DM数据集信息 dataset_info = await self.dm_client.get_dataset(dataset_id) # 获取Label Studio项目任务数量 tasks_info = await self.ls_client.get_project_tasks(mapping.labeling_project_id) return { "id": mapping.id, "dataset_id": dataset_id, "labeling_project_id": mapping.labeling_project_id, "dm_total_files": dataset_info.fileCount if dataset_info else 0, "ls_total_tasks": tasks_info.get("count", 0) if tasks_info else 0, "sync_ratio": ( tasks_info.get("count", 0) / dataset_info.fileCount if dataset_info and dataset_info.fileCount > 0 and tasks_info else 0 ) }