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

526 lines
25 KiB
Java
Raw Normal View History

2026-04-27 10:27:57 +08:00
package com.labelsys.backend.service;
2026-05-07 00:23:27 +08:00
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
2026-04-27 16:25:39 +08:00
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
2026-04-27 10:27:57 +08:00
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;
2026-04-27 10:27:57 +08:00
import com.labelsys.backend.dto.response.AnnotationResultCompareResponse;
2026-05-08 22:38:32 +08:00
import com.labelsys.backend.dto.response.AnnotationResultDetailResponse;
2026-04-27 10:27:57 +08:00
import com.labelsys.backend.dto.response.AnnotationResultResponse;
import com.labelsys.backend.entity.AnnotationResult;
import com.labelsys.backend.entity.AnnotationResultHistory;
2026-04-27 10:27:57 +08:00
import com.labelsys.backend.entity.SourceResource;
2026-04-29 14:02:01 +08:00
import com.labelsys.backend.enums.AnnotationResultStatus;
2026-05-07 00:23:27 +08:00
import com.labelsys.backend.enums.UserRole;
import com.labelsys.backend.mapper.AnnotationResultHistoryMapper;
2026-04-27 10:27:57 +08:00
import com.labelsys.backend.mapper.AnnotationResultMapper;
import com.labelsys.backend.mapper.SourceResourceMapper;
import com.labelsys.backend.util.IdGenerator;
2026-04-27 10:27:57 +08:00
import lombok.RequiredArgsConstructor;
2026-04-27 16:25:39 +08:00
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;
2026-04-27 10:27:57 +08:00
2026-04-27 16:25:39 +08:00
@Slf4j
2026-04-27 10:27:57 +08:00
@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;
2026-04-27 10:27:57 +08:00
public PageResult<AnnotationResultResponse> pageResults(LoginUser currentUser, AnnotationResultPageQuery query) {
2026-05-08 16:07:12 +08:00
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);
}
2026-04-28 12:15:10 +08:00
2026-05-08 16:07:12 +08:00
var page = new Page<AnnotationResult>(query.pageNo(), query.pageSize());
var resultPage = annotationResultMapper.selectPage(page, wrapper);
2026-04-27 16:25:39 +08:00
2026-05-08 16:07:12 +08:00
var records = resultPage.getRecords().stream()
.map(this::toResponse)
.filter(response -> query.runtimeStatus() == null
2026-05-08 16:25:44 +08:00
|| query.runtimeStatus().equals(response.runtimeStatus().name()))
2026-05-08 16:07:12 +08:00
.toList();
2026-04-27 16:25:39 +08:00
2026-05-08 16:07:12 +08:00
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;
}
2026-04-27 10:27:57 +08:00
}
2026-05-08 22:38:32 +08:00
public AnnotationResultDetailResponse getResult(LoginUser currentUser, Long resultId) {
2026-05-08 16:07:12 +08:00
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);
2026-05-08 22:38:32 +08:00
// 加载文件内容
QaContent qaContent = loadQaContent(result);
DiffContent diffContent = Boolean.TRUE.equals(result.getRequiresManualReview()) ?
loadDiffSummary(result) : null;
return toDetailResponse(result, qaContent, diffContent);
2026-05-08 16:07:12 +08:00
} 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;
2026-04-27 10:27:57 +08:00
}
}
2026-05-08 22:38:32 +08:00
private AnnotationResultDetailResponse toDetailResponse(AnnotationResult result,
QaContent qaContent, DiffContent diffContent) {
2026-05-08 22:38:32 +08:00
// 转换 QA 内容(仅保留 records
AnnotationResultDetailResponse.QaContentDto qaContentDto = new AnnotationResultDetailResponse.QaContentDto(
qaContent.records().stream()
.map(r -> new AnnotationResultDetailResponse.QaRecordDto(
2026-05-09 23:46:56 +08:00
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()))
2026-05-08 22:38:32 +08:00
.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(),
2026-05-09 23:46:56 +08:00
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))
2026-05-08 22:38:32 +08:00
.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) {
2026-05-08 16:07:12 +08:00
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(),
2026-05-09 23:46:56 +08:00
qa.batchId(),
2026-05-08 16:07:12 +08:00
qa.question(),
qa.answer(),
2026-05-09 23:46:56 +08:00
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()
2026-05-08 16:07:12 +08:00
)).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(),
2026-05-09 23:46:56 +08:00
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
2026-05-08 16:07:12 +08:00
)).toList() : List.of();
return new AnnotationResultCompareResponse(
result.getId(),
result.getTaskId(),
result.getResourceId(),
qaRecords,
diffRecords
2026-05-08 16:07:12 +08:00
);
} 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) {
2026-05-08 16:07:12 +08:00
try {
AnnotationResult result = annotationResultMapper.findActiveByIdAndCompanyId(resultId,
currentUser.companyId());
if (result == null) {
throw new BusinessException(ResultCode.NOT_FOUND, "结果不存在");
}
//assertResultPermission(currentUser, result);
2026-05-08 16:07:12 +08:00
// 读取当前 qa.json
QaContent qaContent = loadQaContent(result);
2026-05-09 23:46:56 +08:00
// 更新 qa.json 的 answer 字段和 reviewComment
2026-05-08 16:07:12 +08:00
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;
2026-05-09 23:46:56 +08:00
if (mergedAnswer != null || reviewComment != null) {
2026-05-08 16:07:12 +08:00
return new QaContent.QaRecord(
record.id(),
2026-05-09 23:46:56 +08:00
record.batchId(),
2026-05-08 16:07:12 +08:00
record.question(),
2026-05-09 23:46:56 +08:00
mergedAnswer != null ? mergedAnswer : record.answer(),
false,
record.sourceSegments(),
record.questionCategory(),
record.scores(),
reviewComment != null ? reviewComment : record.reviewComment()
2026-05-08 16:07:12 +08:00
);
}
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(
2026-05-09 23:46:56 +08:00
result.getId(),
currentUser.companyId(),
currentUser.userId());
if (updated == 0) {
// 记录已被其他进程归档
throw new BusinessException(ResultCode.CONFLICT, "记录已被归档");
}
2026-05-08 16:07:12 +08:00
result.setRequiresManualReview(false);
result.setIsDeleted(true);
result.setReviewerId(currentUser.userId());
2026-05-08 16:07:12 +08:00
// 归档到历史表(人工审核后归档)
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;
}
}
2026-04-29 14:02:01 +08:00
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()
);
2026-04-29 14:02:01 +08:00
}
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);
2026-04-27 10:27:57 +08:00
}
}
2026-04-29 14:02:01 +08:00
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);
}
}
2026-04-29 14:02:01 +08:00
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, "保存问答内容失败");
}
2026-04-27 10:27:57 +08:00
}
private void assertResultPermission(LoginUser currentUser, AnnotationResult result) {
if (!dataPermissionService.canAccessCreator(currentUser, result.getCreatorId(),
UserRole.valueOf(result.getCreatorRole()))) {
throw new BusinessException(ResultCode.FORBIDDEN, "无权访问该标注结果");
}
}
/**
* 归档到历史表
2026-05-08 16:07:12 +08:00
*
* @param result 标注结果
* @param currentUser 当前用户
* @param archiveReason 归档原因
* @param isAutoArchive 是否自动归档true=自动归档false=人工审核后归档
*/
2026-05-08 16:07:12 +08:00
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) {
2026-05-09 23:46:56 +08:00
// 自动归档reviewer_id为NULLname为"auto"
historyBuilder
.reviewerId(null)
2026-05-09 23:46:56 +08:00
.reviewerName("auto");
} else {
// 人工审核后归档:使用审核人信息
historyBuilder
.reviewerId(currentUser.userId())
2026-05-09 23:46:56 +08:00
.reviewerName(currentUser.realName());
}
annotationResultHistoryMapper.insert(historyBuilder.build());
2026-05-08 16:07:12 +08:00
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, "归档失败");
2026-04-29 14:02:01 +08:00
}
2026-04-27 10:27:57 +08:00
}
2026-04-29 14:02:01 +08:00
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) {
2026-05-09 23:46:56 +08:00
}
private record SourceSegments(String segment, Integer chunkIndex, String chunkTitle, String chunkContent) {
}
private record Scores(Double similarity, Double confidence1, Double confidence2,
Double hallucination, Double trust) {
2026-04-27 10:27:57 +08:00
}
private record Metadata(String createdAt, String updatedAt) {
2026-04-29 14:02:01 +08:00
}
}
// 内部类diff.json 结构
private record DiffContent(
Long taskId,
Long resourceId,
List<DiffRecord> records,
Metadata metadata
) {
private record DiffRecord(String qaId, String question, String extractAnswer,
2026-05-09 23:46:56 +08:00
String verifyAnswer, String diffReason, String mergedAnswer,
String questionCategory, Scores scores) {
}
private record Scores(Double similarity, Double confidence1, Double confidence2,
Double hallucination, Double trust) {
2026-04-29 14:02:01 +08:00
}
private record Metadata(String createdAt) {
2026-04-27 10:27:57 +08:00
}
}
}