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

370 lines
16 KiB
Java
Raw Normal View History

2026-04-27 10:27:57 +08:00
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.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.AnnotationResultHistoryPageQuery;
2026-05-09 23:46:56 +08:00
import com.labelsys.backend.dto.response.AnnotationResultHistoryDetailResponse;
import com.labelsys.backend.dto.response.AnnotationResultHistoryResponse;
import com.labelsys.backend.dto.response.FileContentResponse;
2026-04-27 10:27:57 +08:00
import com.labelsys.backend.dto.response.MergeReviewResultResponse;
import com.labelsys.backend.entity.AnnotationResult;
import com.labelsys.backend.entity.AnnotationResultHistory;
import com.labelsys.backend.enums.UserPosition;
import com.labelsys.backend.enums.UserRole;
2026-04-27 10:27:57 +08:00
import com.labelsys.backend.mapper.AnnotationResultHistoryMapper;
import com.labelsys.backend.mapper.AnnotationResultMapper;
import com.labelsys.backend.util.IdGenerator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
2026-05-09 23:46:56 +08:00
import static org.springframework.util.StringUtils.hasText;
2026-04-27 10:27:57 +08:00
@Slf4j
@Service
@RequiredArgsConstructor
public class AnnotationResultArchiveService {
private static final String MANUAL_ARCHIVE_REASON = "MANUAL_REVIEW";
private final AnnotationResultMapper annotationResultMapper;
2026-04-27 10:27:57 +08:00
private final AnnotationResultHistoryMapper annotationResultHistoryMapper;
private final ObjectStorageService objectStorageService;
private final ObjectMapper objectMapper;
private final DataPermissionService dataPermissionService;
2026-04-27 10:27:57 +08:00
@Value("${labelsys.annotation.auto-archive-timeout:PT2H}")
private Duration autoArchiveTimeout;
public PageResult<AnnotationResultHistoryResponse> pageHistory(LoginUser currentUser,
AnnotationResultHistoryPageQuery 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<AnnotationResultHistory>()
.eq(AnnotationResultHistory::getCompanyId, currentUser.companyId())
.eq(query.taskId() != null, AnnotationResultHistory::getTaskId, query.taskId())
.eq(query.resourceId() != null, AnnotationResultHistory::getResourceId, query.resourceId())
.orderByDesc(AnnotationResultHistory::getCreatedAt);
if (shouldFilterByUserId) {
wrapper.eq(AnnotationResultHistory::getCreatorId, currentUser.userId());
} else if (!allowedRoles.isEmpty()) {
wrapper.in(AnnotationResultHistory::getCreatorRole, allowedRoles);
}
2026-04-27 10:27:57 +08:00
2026-05-08 16:07:12 +08:00
var page = new Page<AnnotationResultHistory>(query.pageNo(), query.pageSize());
var resultPage = annotationResultHistoryMapper.selectPage(page, wrapper);
2026-04-27 10:27:57 +08:00
2026-05-09 23:46:56 +08:00
// 分页查询不加载 qa 内容
2026-05-08 16:07:12 +08:00
var records = resultPage.getRecords().stream()
.map(this::toResponse)
.toList();
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("pageHistory failed, companyId={}, userId={}, error={}",
currentUser.companyId(), currentUser.userId(), e.getMessage(), e);
throw e;
}
}
2026-05-09 23:46:56 +08:00
public AnnotationResultHistoryDetailResponse getHistory(LoginUser currentUser, Long historyId) {
2026-05-08 16:07:12 +08:00
try {
AnnotationResultHistory history = annotationResultHistoryMapper.selectById(historyId);
if (history == null || !history.getCompanyId().equals(currentUser.companyId())) {
throw new BusinessException(ResultCode.NOT_FOUND, "历史记录不存在");
}
assertHistoryPermission(currentUser, history);
2026-05-09 23:46:56 +08:00
// 详情查询加载 QA 内容
QaContent qaContent = loadQaContent(history.getQaContentFilePath());
return toDetailResponse(history, qaContent);
2026-05-08 16:07:12 +08:00
} catch (Exception e) {
log.error("getHistory failed, companyId={}, userId={}, historyId={}, error={}",
currentUser.companyId(), currentUser.userId(), historyId, e.getMessage(), e);
throw e;
2026-04-27 10:27:57 +08:00
}
}
2026-04-27 10:27:57 +08:00
private void assertHistoryPermission(LoginUser currentUser, AnnotationResultHistory history) {
if (!dataPermissionService.canAccessCreator(currentUser, history.getCreatorId(),
UserRole.valueOf(history.getCreatorRole()))) {
throw new BusinessException(ResultCode.FORBIDDEN, "无权访问该归档记录");
}
}
private AnnotationResultHistoryResponse toResponse(AnnotationResultHistory history) {
return new AnnotationResultHistoryResponse(
history.getId(),
history.getTaskId(),
2026-05-09 23:46:56 +08:00
history.getTaskName(),
history.getResourceId(),
history.getResourceName(),
history.getQaContentFilePath(),
history.getArchiveReason(),
history.getArchivedBy(),
history.getArchivedAt(),
history.getCreatedAt(),
history.getReviewerId(),
history.getReviewerName()
);
}
private AnnotationResultHistoryDetailResponse toDetailResponse(AnnotationResultHistory history,
QaContent qaContent) {
// 转换 QA 内容
AnnotationResultHistoryDetailResponse.QaContentDto qaContentDto = null;
if (qaContent != null && qaContent.records() != null) {
qaContentDto = new AnnotationResultHistoryDetailResponse.QaContentDto(
qaContent.records().stream()
.map(r -> new AnnotationResultHistoryDetailResponse.QaRecordDto(
r.id(),
r.batchId(),
r.question(),
r.answer(),
r.requiresReview(),
r.sourceSegments() != null ?
new AnnotationResultHistoryDetailResponse.SourceSegmentsDto(
r.sourceSegments().segment(),
r.sourceSegments().chunkIndex(),
r.sourceSegments().chunkTitle(),
r.sourceSegments().chunkContent()) :
null,
r.questionCategory(),
r.scores() != null ? new AnnotationResultHistoryDetailResponse.ScoresDto(
r.scores().similarity(),
r.scores().confidence1(),
r.scores().confidence2(),
r.scores().hallucination(),
r.scores().trust()) : null,
r.reviewComment()))
.toList()
);
}
return new AnnotationResultHistoryDetailResponse(
history.getId(),
history.getTaskId(),
history.getTaskName(),
history.getResourceId(),
2026-05-09 23:46:56 +08:00
history.getResourceName(),
history.getQaContentFilePath(),
2026-05-09 23:46:56 +08:00
qaContentDto,
history.getArchiveReason(),
history.getArchivedBy(),
history.getArchivedAt(),
history.getCreatedAt(),
history.getReviewerId(),
2026-05-09 23:46:56 +08:00
history.getReviewerName()
);
2026-04-27 10:27:57 +08:00
}
@Transactional
public int autoArchiveEligibleResults() {
2026-05-08 16:07:12 +08:00
try {
LocalDateTime cutoff = LocalDateTime.now().minus(autoArchiveTimeout);
2026-05-09 23:46:56 +08:00
List<AnnotationResult> results = annotationResultMapper.selectList(
new LambdaQueryWrapper<AnnotationResult>()
.eq(AnnotationResult::getIsDeleted, false)
.eq(AnnotationResult::getRequiresManualReview, false)
.lt(AnnotationResult::getCreatedAt, cutoff));
2026-05-08 16:07:12 +08:00
int archivedCount = 0;
for (AnnotationResult result : results) {
2026-05-09 23:46:56 +08:00
if (archiveRuntimeResult(result, null, "AUTO_ARCHIVE") != null) {
2026-05-08 16:07:12 +08:00
archivedCount++;
}
2026-04-27 10:27:57 +08:00
}
2026-05-08 16:07:12 +08:00
return archivedCount;
} catch (Exception e) {
log.error("autoArchiveEligibleResults failed, error={}", e.getMessage(), e);
throw e;
2026-04-27 10:27:57 +08:00
}
}
private void assertReviewer(LoginUser currentUser) {
if (currentUser.position() != UserPosition.REVIEWER && currentUser.position() != UserPosition.ADMIN) {
throw new BusinessException(ResultCode.FORBIDDEN, "当前用户没有审核权限");
}
}
/**
* 归档运行态标注结果到历史表
* 从对象存储读取 qa.json 内容进行归档
*/
2026-04-27 10:27:57 +08:00
private MergeReviewResultResponse archiveRuntimeResult(AnnotationResult result,
Long reviewerId,
2026-05-09 23:46:56 +08:00
String archiveReason) {
2026-04-27 10:27:57 +08:00
LocalDateTime archivedAt = LocalDateTime.now();
int updated = annotationResultMapper.markArchived(
result.getId(),
result.getCompanyId(),
reviewerId);
if (updated == 0) {
return null;
}
2026-04-27 10:27:57 +08:00
AnnotationResultHistory history = 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(qaContentJson) // 使用从对象存储读取的内容
.qaContentFilePath(result.getQaContentFilePath())
.archiveReason(archiveReason)
.archivedBy(reviewerId)
.archivedAt(archivedAt)
.createdAt(archivedAt)
.reviewerId(null)
.reviewerName("auto")
.build();
2026-04-27 10:27:57 +08:00
annotationResultHistoryMapper.insert(history);
return new MergeReviewResultResponse(result.getId(), history.getId(), archiveReason, archivedAt);
}
2026-05-09 23:46:56 +08:00
/**
* 加载 QA 内容
*/
private QaContent loadQaContent(String filePath) {
try {
if (!hasText(filePath)) {
log.warn("QA content file path is empty");
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 com.fasterxml.jackson.core.type.TypeReference<QaContent>() {
});
} catch (Exception e) {
log.warn("Failed to load QA content, returning empty content. filePath={}, error={}", filePath,
e.getMessage());
return new QaContent(null, null, List.of(), null);
}
}
// 内部类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) {
}
}
/**
* 从对象存储读取 qa.json 内容
*/
private String loadQaContentJson(AnnotationResult result) {
try {
String filePath = result.getQaContentFilePath();
if (filePath == null || filePath.isEmpty()) {
log.warn("qa_content_file_path is null or empty, resultId={}", result.getId());
return "{}";
}
// 解析文件路径,提取 bucket 和 object key
String bucketName = extractBucketName(filePath);
String objectKey = extractObjectKey(filePath);
// 从对象存储下载文件内容
byte[] content = objectStorageService.download(bucketName, objectKey);
return new String(content, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("Failed to load qa content from object storage, resultId={}, filePath={}",
result.getId(), result.getQaContentFilePath(), e);
// 如果读取失败,返回空 JSON
return "{}";
}
}
/**
* 从文件路径中提取 bucket 名称
* 例如annotation-results/2/qa/801.json -> annotation-results
*/
private String extractBucketName(String filePath) {
int firstSlash = filePath.indexOf('/');
return firstSlash > 0 ? filePath.substring(0, firstSlash) : filePath;
}
/**
* 从文件路径中提取 object key
* 例如annotation-results/2/qa/801.json -> 2/qa/801.json
*/
private String extractObjectKey(String filePath) {
int firstSlash = filePath.indexOf('/');
return firstSlash > 0 ? filePath.substring(firstSlash + 1) : "";
}
/**
* 加载归档记录的文件内容
2026-05-09 23:46:56 +08:00
*
* @param currentUser 当前用户
2026-05-09 23:46:56 +08:00
* @param historyId 历史记录ID
* @return 文件内容响应
*/
public FileContentResponse loadFileContent(LoginUser currentUser, Long historyId) {
try {
2026-05-08 16:07:12 +08:00
AnnotationResultHistory history = annotationResultHistoryMapper.selectById(historyId);
if (history == null || !history.getCompanyId().equals(currentUser.companyId())) {
throw new BusinessException(ResultCode.NOT_FOUND, "历史记录不存在");
}
//assertHistoryPermission(currentUser, history);
2026-05-09 23:46:56 +08:00
2026-05-08 16:07:12 +08:00
String filePath = history.getQaContentFilePath();
if (filePath == null || filePath.isEmpty()) {
throw new BusinessException(ResultCode.ERROR, "文件路径为空");
}
2026-05-09 23:46:56 +08:00
String bucketName = extractBucketName(filePath);
String objectKey = extractObjectKey(filePath);
byte[] content = objectStorageService.download(bucketName, objectKey);
String contentStr = new String(content, StandardCharsets.UTF_8);
2026-05-09 23:46:56 +08:00
return new FileContentResponse(filePath, contentStr, content.length);
2026-05-08 16:07:12 +08:00
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
2026-05-08 16:07:12 +08:00
log.error("loadFileContent failed, companyId={}, userId={}, historyId={}, error={}",
currentUser.companyId(), currentUser.userId(), historyId, e.getMessage(), e);
throw new BusinessException(ResultCode.ERROR, "加载文件内容失败");
}
}
2026-05-09 23:46:56 +08:00
}