2026-04-14 13:45:15 +08:00
|
|
|
|
package com.label.service;
|
2026-04-09 15:36:11 +08:00
|
|
|
|
|
|
|
|
|
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
|
|
import com.label.common.ai.AiServiceClient;
|
|
|
|
|
|
import com.label.common.exception.BusinessException;
|
|
|
|
|
|
import com.label.common.shiro.TokenPrincipal;
|
|
|
|
|
|
import com.label.common.statemachine.StateValidator;
|
|
|
|
|
|
import com.label.common.statemachine.TaskStatus;
|
2026-04-14 13:39:24 +08:00
|
|
|
|
import com.label.entity.AnnotationResult;
|
|
|
|
|
|
import com.label.entity.TrainingDataset;
|
2026-04-14 13:19:39 +08:00
|
|
|
|
import com.label.event.ExtractionApprovedEvent;
|
2026-04-14 13:39:24 +08:00
|
|
|
|
import com.label.mapper.AnnotationResultMapper;
|
|
|
|
|
|
import com.label.mapper.TrainingDatasetMapper;
|
|
|
|
|
|
import com.label.entity.SourceData;
|
|
|
|
|
|
import com.label.mapper.SourceDataMapper;
|
|
|
|
|
|
import com.label.entity.AnnotationTask;
|
|
|
|
|
|
import com.label.mapper.AnnotationTaskMapper;
|
2026-04-14 13:45:15 +08:00
|
|
|
|
import com.label.service.TaskClaimService;
|
2026-04-09 15:36:11 +08:00
|
|
|
|
import lombok.RequiredArgsConstructor;
|
|
|
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
|
|
|
|
import org.springframework.context.ApplicationEventPublisher;
|
|
|
|
|
|
import org.springframework.http.HttpStatus;
|
|
|
|
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
|
|
|
|
|
|
|
|
|
|
import java.time.LocalDateTime;
|
|
|
|
|
|
import java.util.Collections;
|
|
|
|
|
|
import java.util.Map;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 提取阶段标注服务:AI 预标注、更新结果、提交、审批、驳回。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 关键设计:
|
|
|
|
|
|
* - approve() 内禁止直接调用 AI,通过 ExtractionApprovedEvent 解耦(AFTER_COMMIT)
|
|
|
|
|
|
* - 所有写操作包裹在 @Transactional 中,确保任务状态和历史的一致性
|
|
|
|
|
|
*/
|
2026-04-09 15:36:11 +08:00
|
|
|
|
@Slf4j
|
|
|
|
|
|
@Service
|
|
|
|
|
|
@RequiredArgsConstructor
|
|
|
|
|
|
public class ExtractionService {
|
|
|
|
|
|
|
|
|
|
|
|
private final AnnotationTaskMapper taskMapper;
|
|
|
|
|
|
private final AnnotationResultMapper resultMapper;
|
|
|
|
|
|
private final TrainingDatasetMapper datasetMapper;
|
|
|
|
|
|
private final SourceDataMapper sourceDataMapper;
|
|
|
|
|
|
private final TaskClaimService taskClaimService;
|
|
|
|
|
|
private final AiServiceClient aiServiceClient;
|
|
|
|
|
|
private final ApplicationEventPublisher eventPublisher;
|
|
|
|
|
|
private final ObjectMapper objectMapper;
|
|
|
|
|
|
|
|
|
|
|
|
@Value("${rustfs.bucket:label-source-data}")
|
|
|
|
|
|
private String bucket;
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// ------------------------------------------------------------------ AI 预标注 --
|
2026-04-09 15:36:11 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* AI 辅助预标注:调用 AI 服务,将结果写入 annotation_result。
|
|
|
|
|
|
* 注:此方法在 @Transactional 外调用(AI 调用不应在事务内),由控制器直接调用。
|
|
|
|
|
|
*/
|
2026-04-09 15:36:11 +08:00
|
|
|
|
public void aiPreAnnotate(Long taskId, TokenPrincipal principal) {
|
|
|
|
|
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
|
|
|
|
|
|
|
|
|
|
|
SourceData source = sourceDataMapper.selectById(task.getSourceId());
|
|
|
|
|
|
if (source == null) {
|
2026-04-14 13:31:50 +08:00
|
|
|
|
throw new BusinessException("NOT_FOUND", "关联资料不存在", HttpStatus.NOT_FOUND);
|
2026-04-09 15:36:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 调用 AI 服务(在事务外,避免长时间持有 DB 连接)
|
|
|
|
|
|
AiServiceClient.ExtractionRequest req = AiServiceClient.ExtractionRequest.builder()
|
2026-04-09 15:36:11 +08:00
|
|
|
|
.sourceId(source.getId())
|
|
|
|
|
|
.filePath(source.getFilePath())
|
|
|
|
|
|
.bucket(bucket)
|
|
|
|
|
|
.build();
|
|
|
|
|
|
|
|
|
|
|
|
AiServiceClient.ExtractionResponse aiResponse;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if ("IMAGE".equals(source.getDataType())) {
|
|
|
|
|
|
aiResponse = aiServiceClient.extractImage(req);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
aiResponse = aiServiceClient.extractText(req);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (Exception e) {
|
2026-04-14 13:31:50 +08:00
|
|
|
|
log.warn("AI 预标注调用失败(任务 {}):{}", taskId, e.getMessage());
|
|
|
|
|
|
// AI 失败不阻塞流程,写入空结果
|
|
|
|
|
|
aiResponse = new AiServiceClient.ExtractionResponse();
|
2026-04-09 15:36:11 +08:00
|
|
|
|
aiResponse.setItems(Collections.emptyList());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 将 AI 结果写入 annotation_result(UPSERT 语义)
|
|
|
|
|
|
writeOrUpdateResult(taskId, principal.getCompanyId(), aiResponse.getItems());
|
2026-04-09 15:36:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// ------------------------------------------------------------------ 更新结果 --
|
2026-04-09 15:36:11 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 人工更新标注结果(整体覆盖,PUT 语义)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param taskId 任务 ID
|
|
|
|
|
|
* @param resultJson 新的标注结果 JSON 字符串
|
|
|
|
|
|
* @param principal 当前用户
|
2026-04-09 15:36:11 +08:00
|
|
|
|
*/
|
|
|
|
|
|
@Transactional
|
|
|
|
|
|
public void updateResult(Long taskId, String resultJson, TokenPrincipal principal) {
|
|
|
|
|
|
validateAndGetTask(taskId, principal.getCompanyId());
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 校验 JSON 格式
|
2026-04-09 15:36:11 +08:00
|
|
|
|
try {
|
|
|
|
|
|
objectMapper.readTree(resultJson);
|
|
|
|
|
|
} catch (Exception e) {
|
2026-04-14 13:31:50 +08:00
|
|
|
|
throw new BusinessException("INVALID_JSON", "标注结果 JSON 格式不合法", HttpStatus.BAD_REQUEST);
|
2026-04-09 15:36:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int updated = resultMapper.updateResultJson(taskId, resultJson, principal.getCompanyId());
|
|
|
|
|
|
if (updated == 0) {
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 不存在则新建
|
2026-04-09 15:36:11 +08:00
|
|
|
|
AnnotationResult result = new AnnotationResult();
|
|
|
|
|
|
result.setTaskId(taskId);
|
|
|
|
|
|
result.setCompanyId(principal.getCompanyId());
|
|
|
|
|
|
result.setResultJson(resultJson);
|
|
|
|
|
|
resultMapper.insert(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// ------------------------------------------------------------------ 提交 --
|
2026-04-09 15:36:11 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 提交提取结果(IN_PROGRESS → SUBMITTED)。
|
|
|
|
|
|
*/
|
2026-04-09 15:36:11 +08:00
|
|
|
|
@Transactional
|
|
|
|
|
|
public void submit(Long taskId, TokenPrincipal principal) {
|
|
|
|
|
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
|
|
|
|
|
|
|
|
|
|
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
|
|
|
|
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.SUBMITTED);
|
|
|
|
|
|
|
|
|
|
|
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
|
|
|
|
|
.eq(AnnotationTask::getId, taskId)
|
|
|
|
|
|
.set(AnnotationTask::getStatus, "SUBMITTED")
|
|
|
|
|
|
.set(AnnotationTask::getSubmittedAt, LocalDateTime.now()));
|
|
|
|
|
|
|
|
|
|
|
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
|
|
|
|
|
task.getStatus(), "SUBMITTED",
|
|
|
|
|
|
principal.getUserId(), principal.getRole(), null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// ------------------------------------------------------------------ 审批通过 --
|
2026-04-09 15:36:11 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 审批通过(SUBMITTED → APPROVED)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 两阶段:
|
|
|
|
|
|
* 1. 同步事务:is_final=true,状态推进,写历史
|
|
|
|
|
|
* 2. 事务提交后(AFTER_COMMIT):AI 生成问答对 → training_dataset → QA 任务 → source_data 状态
|
|
|
|
|
|
*
|
|
|
|
|
|
* 注:AI 调用严禁在此事务内执行。
|
|
|
|
|
|
*/
|
2026-04-09 15:36:11 +08:00
|
|
|
|
@Transactional
|
|
|
|
|
|
public void approve(Long taskId, TokenPrincipal principal) {
|
|
|
|
|
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 自审校验
|
2026-04-09 15:36:11 +08:00
|
|
|
|
if (principal.getUserId().equals(task.getClaimedBy())) {
|
|
|
|
|
|
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
|
2026-04-14 13:31:50 +08:00
|
|
|
|
"不允许审批自己提交的任务", HttpStatus.FORBIDDEN);
|
2026-04-09 15:36:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
|
|
|
|
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.APPROVED);
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 标记为最终结果
|
|
|
|
|
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
2026-04-09 15:36:11 +08:00
|
|
|
|
.eq(AnnotationTask::getId, taskId)
|
|
|
|
|
|
.set(AnnotationTask::getStatus, "APPROVED")
|
|
|
|
|
|
.set(AnnotationTask::getIsFinal, true)
|
|
|
|
|
|
.set(AnnotationTask::getCompletedAt, LocalDateTime.now()));
|
|
|
|
|
|
|
|
|
|
|
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
|
|
|
|
|
"SUBMITTED", "APPROVED",
|
|
|
|
|
|
principal.getUserId(), principal.getRole(), null);
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 获取资料信息,用于事件
|
|
|
|
|
|
SourceData source = sourceDataMapper.selectById(task.getSourceId());
|
2026-04-09 15:36:11 +08:00
|
|
|
|
String sourceType = source != null ? source.getDataType() : "TEXT";
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 发布事件(@TransactionalEventListener(AFTER_COMMIT) 处理 AI 调用)
|
|
|
|
|
|
eventPublisher.publishEvent(new ExtractionApprovedEvent(
|
2026-04-09 15:36:11 +08:00
|
|
|
|
this, taskId, task.getSourceId(), sourceType,
|
|
|
|
|
|
principal.getCompanyId(), principal.getUserId()));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// ------------------------------------------------------------------ 驳回 --
|
2026-04-09 15:36:11 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 驳回提取结果(SUBMITTED → REJECTED)。
|
|
|
|
|
|
*/
|
2026-04-09 15:36:11 +08:00
|
|
|
|
@Transactional
|
|
|
|
|
|
public void reject(Long taskId, String reason, TokenPrincipal principal) {
|
|
|
|
|
|
if (reason == null || reason.isBlank()) {
|
2026-04-14 13:31:50 +08:00
|
|
|
|
throw new BusinessException("REASON_REQUIRED", "驳回原因不能为空", HttpStatus.BAD_REQUEST);
|
2026-04-09 15:36:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 自审校验
|
2026-04-09 15:36:11 +08:00
|
|
|
|
if (principal.getUserId().equals(task.getClaimedBy())) {
|
|
|
|
|
|
throw new BusinessException("SELF_REVIEW_FORBIDDEN",
|
2026-04-14 13:31:50 +08:00
|
|
|
|
"不允许驳回自己提交的任务", HttpStatus.FORBIDDEN);
|
2026-04-09 15:36:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
|
|
|
|
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.REJECTED);
|
|
|
|
|
|
|
|
|
|
|
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
|
|
|
|
|
.eq(AnnotationTask::getId, taskId)
|
|
|
|
|
|
.set(AnnotationTask::getStatus, "REJECTED")
|
|
|
|
|
|
.set(AnnotationTask::getRejectReason, reason));
|
|
|
|
|
|
|
|
|
|
|
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
|
|
|
|
|
"SUBMITTED", "REJECTED",
|
|
|
|
|
|
principal.getUserId(), principal.getRole(), reason);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// ------------------------------------------------------------------ 查询 --
|
2026-04-09 15:36:11 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 获取当前标注结果。
|
|
|
|
|
|
*/
|
2026-04-09 15:36:11 +08:00
|
|
|
|
public Map<String, Object> getResult(Long taskId, TokenPrincipal principal) {
|
|
|
|
|
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
|
|
|
|
|
AnnotationResult result = resultMapper.selectByTaskId(taskId);
|
|
|
|
|
|
SourceData source = sourceDataMapper.selectById(task.getSourceId());
|
|
|
|
|
|
|
|
|
|
|
|
return Map.of(
|
|
|
|
|
|
"taskId", taskId,
|
|
|
|
|
|
"sourceType", source != null ? source.getDataType() : "",
|
|
|
|
|
|
"sourceFilePath", source != null && source.getFilePath() != null ? source.getFilePath() : "",
|
|
|
|
|
|
"isFinal", task.getIsFinal() != null && task.getIsFinal(),
|
|
|
|
|
|
"resultJson", result != null ? result.getResultJson() : "[]"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// ------------------------------------------------------------------ 私有工具 --
|
2026-04-09 15:36:11 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 校验任务存在性(多租户自动过滤)。
|
|
|
|
|
|
*/
|
2026-04-09 15:36:11 +08:00
|
|
|
|
private AnnotationTask validateAndGetTask(Long taskId, Long companyId) {
|
|
|
|
|
|
AnnotationTask task = taskMapper.selectById(taskId);
|
2026-04-09 19:42:20 +08:00
|
|
|
|
if (task == null || !companyId.equals(task.getCompanyId())) {
|
2026-04-14 13:31:50 +08:00
|
|
|
|
throw new BusinessException("NOT_FOUND", "任务不存在: " + taskId, HttpStatus.NOT_FOUND);
|
2026-04-09 15:36:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
return task;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private void writeOrUpdateResult(Long taskId, Long companyId, java.util.List<?> items) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
String json = objectMapper.writeValueAsString(Map.of("items", items != null ? items : Collections.emptyList()));
|
|
|
|
|
|
int updated = resultMapper.updateResultJson(taskId, json, companyId);
|
|
|
|
|
|
if (updated == 0) {
|
|
|
|
|
|
AnnotationResult result = new AnnotationResult();
|
|
|
|
|
|
result.setTaskId(taskId);
|
|
|
|
|
|
result.setCompanyId(companyId);
|
|
|
|
|
|
result.setResultJson(json);
|
|
|
|
|
|
resultMapper.insert(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (Exception e) {
|
2026-04-14 13:31:50 +08:00
|
|
|
|
log.error("写入 AI 预标注结果失败: taskId={}", taskId, e);
|
2026-04-09 15:36:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|