2026-04-14 13:45:15 +08:00
|
|
|
|
package com.label.service;
|
2026-04-09 15:39:28 +08:00
|
|
|
|
|
|
|
|
|
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
|
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
|
|
|
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.TrainingDataset;
|
|
|
|
|
|
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:39:28 +08:00
|
|
|
|
import lombok.RequiredArgsConstructor;
|
|
|
|
|
|
import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
|
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.List;
|
|
|
|
|
|
import java.util.Map;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 问答生成阶段标注服务:查询候选问答对、更新、提交、审批、驳回。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 关键设计:
|
|
|
|
|
|
* - QA 阶段无 AI 调用(候选问答对已由 ExtractionApprovedEventListener 生成)
|
|
|
|
|
|
* - approve() 同一事务内完成:training_dataset → APPROVED、task → APPROVED、source_data → APPROVED
|
|
|
|
|
|
* - reject() 清除候选问答对(deleteByTaskId),source_data 保持 QA_REVIEW 状态
|
|
|
|
|
|
*/
|
2026-04-09 15:39:28 +08:00
|
|
|
|
@Slf4j
|
|
|
|
|
|
@Service
|
|
|
|
|
|
@RequiredArgsConstructor
|
|
|
|
|
|
public class QaService {
|
|
|
|
|
|
|
|
|
|
|
|
private final AnnotationTaskMapper taskMapper;
|
|
|
|
|
|
private final TrainingDatasetMapper datasetMapper;
|
|
|
|
|
|
private final SourceDataMapper sourceDataMapper;
|
|
|
|
|
|
private final TaskClaimService taskClaimService;
|
|
|
|
|
|
private final ObjectMapper objectMapper;
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// ------------------------------------------------------------------ 查询 --
|
2026-04-09 15:39:28 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 获取候选问答对(从 training_dataset.glm_format_json 解析)。
|
|
|
|
|
|
*/
|
2026-04-09 15:39:28 +08:00
|
|
|
|
public Map<String, Object> getResult(Long taskId, TokenPrincipal principal) {
|
|
|
|
|
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
|
|
|
|
|
TrainingDataset dataset = getDataset(taskId);
|
|
|
|
|
|
|
|
|
|
|
|
SourceData source = sourceDataMapper.selectById(task.getSourceId());
|
|
|
|
|
|
String sourceType = source != null ? source.getDataType() : "TEXT";
|
|
|
|
|
|
|
|
|
|
|
|
List<?> items = Collections.emptyList();
|
|
|
|
|
|
if (dataset != null && dataset.getGlmFormatJson() != null) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
@SuppressWarnings("unchecked")
|
|
|
|
|
|
Map<String, Object> parsed = objectMapper.readValue(dataset.getGlmFormatJson(), Map.class);
|
|
|
|
|
|
Object conversations = parsed.get("conversations");
|
|
|
|
|
|
if (conversations instanceof List) {
|
|
|
|
|
|
items = (List<?>) conversations;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (Exception e) {
|
2026-04-14 13:31:50 +08:00
|
|
|
|
log.warn("解析 QA JSON 失败(taskId={}):{}", taskId, e.getMessage());
|
2026-04-09 15:39:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Map.of(
|
|
|
|
|
|
"taskId", taskId,
|
|
|
|
|
|
"sourceType", sourceType,
|
|
|
|
|
|
"items", items
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// ------------------------------------------------------------------ 更新 --
|
2026-04-09 15:39:28 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 整体覆盖问答对(PUT 语义)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param taskId 任务 ID
|
|
|
|
|
|
* @param body 包含 items 数组的 JSON,格式:{"items": [...]}
|
|
|
|
|
|
* @param principal 当前用户
|
2026-04-09 15:39:28 +08:00
|
|
|
|
*/
|
|
|
|
|
|
@Transactional
|
|
|
|
|
|
public void updateResult(Long taskId, String body, TokenPrincipal principal) {
|
|
|
|
|
|
validateAndGetTask(taskId, principal.getCompanyId());
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 校验 JSON 格式
|
2026-04-09 15:39:28 +08:00
|
|
|
|
try {
|
|
|
|
|
|
objectMapper.readTree(body);
|
|
|
|
|
|
} catch (Exception e) {
|
2026-04-14 13:31:50 +08:00
|
|
|
|
throw new BusinessException("INVALID_JSON", "请求体 JSON 格式不合法", HttpStatus.BAD_REQUEST);
|
2026-04-09 15:39:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 将 items 格式包装为 GLM 格式:{"conversations": items}
|
2026-04-09 15:39:28 +08:00
|
|
|
|
String glmJson;
|
|
|
|
|
|
try {
|
|
|
|
|
|
@SuppressWarnings("unchecked")
|
|
|
|
|
|
Map<String, Object> parsed = objectMapper.readValue(body, Map.class);
|
|
|
|
|
|
Object items = parsed.getOrDefault("items", Collections.emptyList());
|
|
|
|
|
|
glmJson = objectMapper.writeValueAsString(Map.of("conversations", items));
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
|
glmJson = "{\"conversations\":[]}";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
TrainingDataset dataset = getDataset(taskId);
|
|
|
|
|
|
if (dataset != null) {
|
|
|
|
|
|
datasetMapper.update(null, new LambdaUpdateWrapper<TrainingDataset>()
|
|
|
|
|
|
.eq(TrainingDataset::getTaskId, taskId)
|
|
|
|
|
|
.set(TrainingDataset::getGlmFormatJson, glmJson)
|
|
|
|
|
|
.set(TrainingDataset::getUpdatedAt, LocalDateTime.now()));
|
|
|
|
|
|
} else {
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 若 training_dataset 不存在(异常情况),自动创建
|
2026-04-09 15:39:28 +08:00
|
|
|
|
TrainingDataset newDataset = new TrainingDataset();
|
|
|
|
|
|
newDataset.setCompanyId(principal.getCompanyId());
|
|
|
|
|
|
newDataset.setTaskId(taskId);
|
|
|
|
|
|
AnnotationTask task = taskMapper.selectById(taskId);
|
|
|
|
|
|
newDataset.setSourceId(task.getSourceId());
|
|
|
|
|
|
newDataset.setSampleType("TEXT");
|
|
|
|
|
|
newDataset.setGlmFormatJson(glmJson);
|
|
|
|
|
|
newDataset.setStatus("PENDING_REVIEW");
|
|
|
|
|
|
datasetMapper.insert(newDataset);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// ------------------------------------------------------------------ 提交 --
|
2026-04-09 15:39:28 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 提交 QA 结果(IN_PROGRESS → SUBMITTED)。
|
|
|
|
|
|
*/
|
2026-04-09 15:39:28 +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:39:28 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 审批通过(SUBMITTED → APPROVED)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 同一事务:
|
|
|
|
|
|
* 1. 校验任务(先于一切 DB 写入)
|
|
|
|
|
|
* 2. 自审校验
|
2026-04-09 15:39:28 +08:00
|
|
|
|
* 3. StateValidator
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 4. training_dataset → APPROVED
|
|
|
|
|
|
* 5. annotation_task → APPROVED + is_final=true + completedAt
|
|
|
|
|
|
* 6. source_data → APPROVED(整条流水线完成)
|
|
|
|
|
|
* 7. 写任务历史
|
|
|
|
|
|
*/
|
2026-04-09 15:39:28 +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:39:28 +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:39:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
|
|
|
|
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.APPROVED);
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// training_dataset → APPROVED
|
2026-04-09 15:39:28 +08:00
|
|
|
|
datasetMapper.approveByTaskId(taskId, principal.getCompanyId());
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// annotation_task → APPROVED + is_final=true
|
2026-04-09 15:39:28 +08:00
|
|
|
|
taskMapper.update(null, new LambdaUpdateWrapper<AnnotationTask>()
|
|
|
|
|
|
.eq(AnnotationTask::getId, taskId)
|
|
|
|
|
|
.set(AnnotationTask::getStatus, "APPROVED")
|
|
|
|
|
|
.set(AnnotationTask::getIsFinal, true)
|
|
|
|
|
|
.set(AnnotationTask::getCompletedAt, LocalDateTime.now()));
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// source_data → APPROVED(整条流水线终态)
|
2026-04-09 15:39:28 +08:00
|
|
|
|
sourceDataMapper.updateStatus(task.getSourceId(), "APPROVED", principal.getCompanyId());
|
|
|
|
|
|
|
|
|
|
|
|
taskClaimService.insertHistory(taskId, principal.getCompanyId(),
|
|
|
|
|
|
"SUBMITTED", "APPROVED",
|
|
|
|
|
|
principal.getUserId(), principal.getRole(), null);
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
log.info("QA 审批通过,整条流水线完成: taskId={}, sourceId={}", taskId, task.getSourceId());
|
2026-04-09 15:39:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// ------------------------------------------------------------------ 驳回 --
|
2026-04-09 15:39:28 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-14 13:31:50 +08:00
|
|
|
|
* 驳回 QA 结果(SUBMITTED → REJECTED)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 清除候选问答对(deleteByTaskId),source_data 保持 QA_REVIEW 状态不变。
|
|
|
|
|
|
*/
|
2026-04-09 15:39:28 +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:39:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
AnnotationTask task = validateAndGetTask(taskId, principal.getCompanyId());
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 自审校验
|
2026-04-09 15:39:28 +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:39:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
StateValidator.assertTransition(TaskStatus.TRANSITIONS,
|
|
|
|
|
|
TaskStatus.valueOf(task.getStatus()), TaskStatus.REJECTED);
|
|
|
|
|
|
|
2026-04-14 13:31:50 +08:00
|
|
|
|
// 清除候选问答对
|
2026-04-09 15:39:28 +08:00
|
|
|
|
datasetMapper.deleteByTaskId(taskId, principal.getCompanyId());
|
|
|
|
|
|
|
|
|
|
|
|
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:39:28 +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:39:28 +08:00
|
|
|
|
}
|
|
|
|
|
|
return task;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private TrainingDataset getDataset(Long taskId) {
|
|
|
|
|
|
return datasetMapper.selectOne(
|
|
|
|
|
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TrainingDataset>()
|
|
|
|
|
|
.eq(TrainingDataset::getTaskId, taskId)
|
|
|
|
|
|
.last("LIMIT 1"));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|