Files
lablesys_backend/src/main/java/com/labelsys/backend/service/AnnotationResultService.java

526 lines
25 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

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

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<AnnotationResultResponse> pageResults(LoginUser currentUser, AnnotationResultPageQuery query) {
try {
List<String> allowedRoles = dataPermissionService.getAllowedRoles(currentUser);
boolean shouldFilterByUserId = dataPermissionService.shouldFilterByUserId(currentUser);
var wrapper = new LambdaQueryWrapper<AnnotationResult>()
.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<AnnotationResult>(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<AnnotationResultCompareResponse.QaRecord> 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<AnnotationResultCompareResponse.DiffRecord> 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
);
} 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<QaContent.QaRecord> 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<QaContent>() {
});
} 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<DiffContent>() {
});
} 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为NULLname为"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<QaRecord> 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<DiffRecord> 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) {
}
}
}