package com.labelsys.backend.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.labelsys.backend.common.ResultCode; import com.labelsys.backend.common.exception.BusinessException; import com.labelsys.backend.context.LoginUser; import com.labelsys.backend.dto.common.PageResult; import com.labelsys.backend.dto.request.AnnotationResultPageQuery; import com.labelsys.backend.dto.request.MergeReviewResultRequest; import com.labelsys.backend.dto.response.AnnotationResultCompareResponse; import com.labelsys.backend.dto.response.AnnotationResultDetailResponse; import com.labelsys.backend.dto.response.AnnotationResultResponse; import com.labelsys.backend.entity.AnnotationResult; import com.labelsys.backend.entity.AnnotationResultHistory; import com.labelsys.backend.entity.SourceResource; import com.labelsys.backend.enums.AnnotationResultStatus; import com.labelsys.backend.enums.UserRole; import com.labelsys.backend.mapper.AnnotationResultHistoryMapper; import com.labelsys.backend.mapper.AnnotationResultMapper; import com.labelsys.backend.mapper.SourceResourceMapper; import com.labelsys.backend.util.IdGenerator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; @Slf4j @Service @RequiredArgsConstructor public class AnnotationResultService { private final AnnotationResultMapper annotationResultMapper; private final AnnotationResultHistoryMapper annotationResultHistoryMapper; private final SourceResourceMapper sourceResourceMapper; private final DataPermissionService dataPermissionService; private final ObjectStorageService objectStorageService; private final ObjectMapper objectMapper; public PageResult pageResults(LoginUser currentUser, AnnotationResultPageQuery query) { try { List allowedRoles = dataPermissionService.getAllowedRoles(currentUser); boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser); var wrapper = new LambdaQueryWrapper() .eq(AnnotationResult::getIsDeleted, Boolean.FALSE) .eq(AnnotationResult::getCompanyId, currentUser.companyId()) .eq(query.taskId() != null, AnnotationResult::getTaskId, query.taskId()) .eq(query.resourceId() != null, AnnotationResult::getResourceId, query.resourceId()) .eq(query.requiresManualReview() != null, AnnotationResult::getRequiresManualReview, query.requiresManualReview()) .orderByDesc(AnnotationResult::getCreatedAt); if (shouldFilterByUserId) { wrapper.eq(AnnotationResult::getCreatorId, currentUser.userId()); } else if (!allowedRoles.isEmpty()) { wrapper.in(AnnotationResult::getCreatorRole, allowedRoles); } var page = new Page(query.pageNo(), query.pageSize()); var resultPage = annotationResultMapper.selectPage(page, wrapper); var records = resultPage.getRecords().stream() .map(this::toResponse) .filter(response -> query.runtimeStatus() == null || query.runtimeStatus().equals(response.runtimeStatus().name())) .toList(); return new PageResult<>(records, resultPage.getTotal(), (int) resultPage.getCurrent(), (int) resultPage.getSize()); } catch (Exception e) { log.error("pageResults failed, companyId={}, userId={}, error={}", currentUser.companyId(), currentUser.userId(), e.getMessage(), e); throw e; } } public AnnotationResultDetailResponse getResult(LoginUser currentUser, Long resultId) { try { AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, currentUser.companyId()); if (result == null) { log.warn("Result not found or cross-tenant access attempt: resultId={}, companyId={}, userId={}", resultId, currentUser.companyId(), currentUser.userId()); throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); } //assertResultPermission(currentUser, result); // 加载文件内容 QaContent qaContent = loadQaContent(result); DiffContent diffContent = Boolean.TRUE.equals(result.getRequiresManualReview()) ? loadDiffSummary(result) : null; return toDetailResponse(result, qaContent, diffContent); } catch (BusinessException e) { throw e; } catch (Exception e) { log.error("getResult failed, companyId={}, userId={}, resultId={}, error={}", currentUser.companyId(), currentUser.userId(), resultId, e.getMessage(), e); throw e; } } private AnnotationResultDetailResponse toDetailResponse(AnnotationResult result, QaContent qaContent, DiffContent diffContent) { // 转换 QA 内容(仅保留 records) AnnotationResultDetailResponse.QaContentDto qaContentDto = new AnnotationResultDetailResponse.QaContentDto( qaContent.records().stream() .map(r -> new AnnotationResultDetailResponse.QaRecordDto( r.id(), r.batchId(), r.question(), r.answer(), r.requiresReview(), r.sourceSegments() != null ? new AnnotationResultDetailResponse.SourceSegmentsDto( r.sourceSegments().segment(), r.sourceSegments().chunkIndex(), r.sourceSegments().chunkTitle(), r.sourceSegments().chunkContent()) : null, r.questionCategory(), r.scores() != null ? new AnnotationResultDetailResponse.ScoresDto( r.scores().similarity(), r.scores().confidence1(), r.scores().confidence2(), r.scores().hallucination(), r.scores().trust()) : null, r.reviewComment())) .toList() ); // 转换差异内容(仅保留 records) AnnotationResultDetailResponse.DiffContentDto diffContentDto = null; if (diffContent != null) { diffContentDto = new AnnotationResultDetailResponse.DiffContentDto( diffContent.records().stream() .map(r -> new AnnotationResultDetailResponse.DiffRecordDto( r.qaId(), r.question(), r.extractAnswer(), r.verifyAnswer(), r.diffReason(), r.mergedAnswer(), r.questionCategory(), r.scores() != null ? new AnnotationResultDetailResponse.ScoresDto( r.scores().similarity(), r.scores().confidence1(), r.scores().confidence2(), r.scores().hallucination(), r.scores().trust()) : null)) .toList() ); } return new AnnotationResultDetailResponse( result.getId(), result.getTaskId(), result.getTaskName(), result.getResourceId(), result.getResourceName(), deriveStatus(result), result.getRequiresManualReview(), result.getIsDeleted(), result.getQaContentFilePath(), result.getDiffSummaryFilePath(), qaContentDto, diffContentDto, result.getCreatedAt() ); } public AnnotationResultCompareResponse compareResult(LoginUser currentUser, Long resultId) { try { AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, currentUser.companyId()); if (result == null) { log.warn("Result not found or cross-tenant access attempt: resultId={}, companyId={}, userId={}", resultId, currentUser.companyId(), currentUser.userId()); throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); } //assertResultPermission(currentUser, result); QaContent qaContent = loadQaContent(result); DiffContent diffContent = Boolean.TRUE.equals(result.getRequiresManualReview()) ? loadDiffSummary(result) : null; SourceResource resource = sourceResourceMapper.selectById(result.getResourceId()); // 转换 QA 记录 List qaRecords = qaContent.records().stream() .map(qa -> new AnnotationResultCompareResponse.QaRecord( qa.id(), qa.batchId(), qa.question(), qa.answer(), qa.requiresReview(), qa.sourceSegments() != null ? new AnnotationResultCompareResponse.SourceSegments( qa.sourceSegments().segment(), qa.sourceSegments().chunkIndex(), qa.sourceSegments().chunkTitle(), qa.sourceSegments().chunkContent()) : null, qa.questionCategory(), qa.scores() != null ? new AnnotationResultCompareResponse.Scores( qa.scores().similarity(), qa.scores().confidence1(), qa.scores().confidence2(), qa.scores().hallucination(), qa.scores().trust()) : null, qa.reviewComment() )).toList(); // 转换差异记录 List diffRecords = diffContent != null ? diffContent.records().stream() .map(diff -> new AnnotationResultCompareResponse.DiffRecord( diff.qaId(), diff.question(), diff.extractAnswer(), diff.verifyAnswer(), diff.diffReason(), diff.mergedAnswer(), diff.questionCategory(), diff.scores() != null ? new AnnotationResultCompareResponse.Scores( diff.scores().similarity(), diff.scores().confidence1(), diff.scores().confidence2(), diff.scores().hallucination(), diff.scores().trust()) : null )).toList() : List.of(); return new AnnotationResultCompareResponse( result.getId(), result.getTaskId(), result.getResourceId(), qaRecords, diffRecords, resource == null ? null : resource.getFilePath() ); } catch (BusinessException e) { throw e; } catch (Exception e) { log.error("compareResult failed, companyId={}, userId={}, resultId={}, error={}", currentUser.companyId(), currentUser.userId(), resultId, e.getMessage(), e); throw e; } } @Transactional public void mergeReviewResult(LoginUser currentUser, Long resultId, MergeReviewResultRequest request) { try { AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId, currentUser.companyId()); if (result == null) { throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在"); } //assertResultPermission(currentUser, result); // 读取当前 qa.json QaContent qaContent = loadQaContent(result); // 更新 qa.json 的 answer 字段和 reviewComment List updatedQaRecords = qaContent.records().stream() .map(record -> { String mergedAnswer = request.mergedAnswers().get(record.id()); String reviewComment = request.reviewComments() != null ? request.reviewComments().get(record.id()) : null; if (mergedAnswer != null || reviewComment != null) { return new QaContent.QaRecord( record.id(), record.batchId(), record.question(), mergedAnswer != null ? mergedAnswer : record.answer(), false, record.sourceSegments(), record.questionCategory(), record.scores(), reviewComment != null ? reviewComment : record.reviewComment() ); } return record; }) .toList(); QaContent updatedQaContent = new QaContent( qaContent.taskId(), qaContent.resourceId(), updatedQaRecords, new QaContent.Metadata( qaContent.metadata().createdAt(), LocalDateTime.now().toString() ) ); saveQaContent(result, updatedQaContent); // 用单条 SQL 原子完成人工审核归档,避免状态部分更新后再次被自动归档扫描到。 int updated = annotationResultMapper.markReviewedAndArchived( result.getId(), currentUser.companyId(), currentUser.userId()); if (updated == 0) { // 记录已被其他进程归档 throw new BusinessException(ResultCode.CONFLICT, "记录已被归档"); } result.setRequiresManualReview(false); result.setIsDeleted(true); result.setReviewerId(currentUser.userId()); // 归档到历史表(人工审核后归档) archiveToHistory(result, currentUser, "审核通过后归档", false); log.info("merged review result, companyId={}, userId={}, resultId={}", currentUser.companyId(), currentUser.userId(), resultId); } catch (BusinessException e) { throw e; } catch (Exception e) { log.error("mergeReviewResult failed, companyId={}, userId={}, resultId={}, error={}", currentUser.companyId(), currentUser.userId(), resultId, e.getMessage(), e); throw e; } } private AnnotationResultResponse toResponse(AnnotationResult result) { return new AnnotationResultResponse( result.getId(), result.getTaskId(), result.getTaskName(), // 新增 result.getResourceId(), result.getResourceName(), // 新增 deriveStatus(result), result.getRequiresManualReview(), result.getIsDeleted(), result.getQaContentFilePath(), result.getDiffSummaryFilePath(), result.getCreatedAt() ); } private AnnotationResultStatus deriveStatus(AnnotationResult result) { if (Boolean.TRUE.equals(result.getIsDeleted())) { return AnnotationResultStatus.ARCHIVED; } if (Boolean.TRUE.equals(result.getRequiresManualReview())) { return AnnotationResultStatus.MANUAL_REVIEW_PENDING; } return AnnotationResultStatus.AUTO_ARCHIVE_PENDING; } private QaContent loadQaContent(AnnotationResult result) { try { String filePath = result.getQaContentFilePath(); if (!StringUtils.hasText(filePath)) { log.warn("Qa content file path is empty, resultId={}", result.getId()); return new QaContent(null, null, List.of(), null); } String bucketName = extractBucketName(filePath); String objectKey = extractObjectKey(filePath); byte[] content = objectStorageService.download(bucketName, objectKey); String jsonContent = new String(content, StandardCharsets.UTF_8); return objectMapper.readValue(jsonContent, new TypeReference() { }); } catch (Exception e) { log.warn("Failed to load qa content, returning empty content. resultId={}, filePath={}, error={}", result.getId(), result.getQaContentFilePath(), e.getMessage()); return new QaContent(null, null, List.of(), null); } } private DiffContent loadDiffSummary(AnnotationResult result) { try { String filePath = result.getDiffSummaryFilePath(); if (!StringUtils.hasText(filePath)) { log.warn("Diff summary file path is empty, resultId={}", result.getId()); return new DiffContent(null, null, List.of(), null); } String bucketName = extractBucketName(filePath); String objectKey = extractObjectKey(filePath); byte[] content = objectStorageService.download(bucketName, objectKey); String jsonContent = new String(content, StandardCharsets.UTF_8); return objectMapper.readValue(jsonContent, new TypeReference() { }); } catch (Exception e) { log.warn("Failed to load diff summary, returning empty content. resultId={}, filePath={}, error={}", result.getId(), result.getDiffSummaryFilePath(), e.getMessage()); return new DiffContent(null, null, List.of(), null); } } private void saveQaContent(AnnotationResult result, QaContent qaContent) { try { String jsonContent = objectMapper.writeValueAsString(qaContent); String filePath = result.getQaContentFilePath(); String bucketName = extractBucketName(filePath); String objectKey = extractObjectKey(filePath); objectStorageService.upload(bucketName, objectKey, jsonContent.getBytes(StandardCharsets.UTF_8), "application/json"); } catch (Exception e) { log.error("Failed to save qa content, resultId={}", result.getId(), e); throw new BusinessException(ResultCode.ERROR, "保存问答内容失败"); } } private void assertResultPermission(LoginUser currentUser, AnnotationResult result) { if (!dataPermissionService.canAccessCreator(currentUser, result.getCreatorId(), UserRole.valueOf(result.getCreatorRole()))) { throw new BusinessException(ResultCode.FORBIDDEN, "无权访问该标注结果"); } } /** * 归档到历史表 * * @param result 标注结果 * @param currentUser 当前用户 * @param archiveReason 归档原因 * @param isAutoArchive 是否自动归档(true=自动归档,false=人工审核后归档) */ private void archiveToHistory(AnnotationResult result, LoginUser currentUser, String archiveReason, boolean isAutoArchive) { try { // 读取 qa.json 内容用于归档 QaContent qaContent = loadQaContent(result); // 构建归档记录 AnnotationResultHistory.AnnotationResultHistoryBuilder historyBuilder = AnnotationResultHistory.builder() .id(IdGenerator.nextId()) .companyId(result.getCompanyId()) .creatorId(result.getCreatorId()) .creatorRole(result.getCreatorRole()) .sourceResultId(result.getId()) .taskId(result.getTaskId()) .taskName(result.getTaskName()) .resourceId(result.getResourceId()) .resourceName(result.getResourceName()) //.qaContentJson(objectMapper.writeValueAsString(qaContent)) .qaContentFilePath(result.getQaContentFilePath()) .archiveReason(archiveReason) .archivedBy(currentUser.userId()) .archivedAt(LocalDateTime.now()) .createdAt(LocalDateTime.now()); // 根据归档类型设置审核人信息 if (isAutoArchive) { // 自动归档:reviewer_id为NULL,name为"auto" historyBuilder .reviewerId(null) .reviewerName("auto"); } else { // 人工审核后归档:使用审核人信息 historyBuilder .reviewerId(currentUser.userId()) .reviewerName(currentUser.realName()); } annotationResultHistoryMapper.insert(historyBuilder.build()); log.info("archived result to history, resultId={}, historyId={}, isAutoArchive={}", result.getId(), historyBuilder.build().getId(), isAutoArchive); } catch (Exception e) { log.error("Failed to archive result to history, resultId={}", result.getId(), e); throw new BusinessException(ResultCode.ERROR, "归档失败"); } } private String extractBucketName(String filePath) { // 从文件路径中提取 bucket 名称 // 例如:annotation-results/2/qa/801.json -> annotation-results int firstSlash = filePath.indexOf('/'); return firstSlash > 0 ? filePath.substring(0, firstSlash) : filePath; } private String extractObjectKey(String filePath) { // 从文件路径中提取 object key // 例如:annotation-results/2/qa/801.json -> 2/qa/801.json int firstSlash = filePath.indexOf('/'); return firstSlash > 0 ? filePath.substring(firstSlash + 1) : ""; } // 内部类:qa.json 结构 private record QaContent( Long taskId, Long resourceId, List records, Metadata metadata ) { private record QaRecord(String id, Long batchId, String question, String answer, Boolean requiresReview, SourceSegments sourceSegments, String questionCategory, Scores scores, String reviewComment) { } private record SourceSegments(String segment, Integer chunkIndex, String chunkTitle, String chunkContent) { } private record Scores(Double similarity, Double confidence1, Double confidence2, Double hallucination, Double trust) { } private record Metadata(String createdAt, String updatedAt) { } } // 内部类:diff.json 结构 private record DiffContent( Long taskId, Long resourceId, List records, Metadata metadata ) { private record DiffRecord(String qaId, String question, String extractAnswer, String verifyAnswer, String diffReason, String mergedAnswer, String questionCategory, Scores scores) { } private record Scores(Double similarity, Double confidence1, Double confidence2, Double hallucination, Double trust) { } private record Metadata(String createdAt) { } } }