18 changed files with 716 additions and 0 deletions
@ -0,0 +1,99 @@ |
|||||
|
package com.project.exam.domain.entity; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.annotation.IdType; |
||||
|
import com.baomidou.mybatisplus.annotation.TableField; |
||||
|
import com.baomidou.mybatisplus.annotation.TableId; |
||||
|
import com.baomidou.mybatisplus.annotation.TableName; |
||||
|
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; |
||||
|
import com.project.base.domain.entity.BaseEntity; |
||||
|
import jakarta.persistence.*; |
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
import org.hibernate.annotations.Comment; |
||||
|
import org.hibernate.annotations.JdbcTypeCode; |
||||
|
import org.hibernate.type.SqlTypes; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* AI判分日志实体 |
||||
|
* 记录每次AI阅卷的请求/响应,用于审计和统计 |
||||
|
*/ |
||||
|
@Data |
||||
|
@Table(name = "evaluator_ai_grading_log", |
||||
|
indexes = {@Index(name = "Idx_exam_record_id", columnList = "exam_record_id"), |
||||
|
@Index(name = "Idx_question_id", columnList = "question_id")}) |
||||
|
@Entity |
||||
|
@TableName(value = "evaluator_ai_grading_log", autoResultMap = true) |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
public class AiGradingLogEntity extends BaseEntity { |
||||
|
@TableId(value = "id", type = IdType.ASSIGN_ID) |
||||
|
@Id |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "exam_record_id") |
||||
|
@TableField("exam_record_id") |
||||
|
@Comment("考试记录ID") |
||||
|
private Long examRecordId; |
||||
|
|
||||
|
@Column(name = "question_id") |
||||
|
@TableField("question_id") |
||||
|
@Comment("题目ID") |
||||
|
private Long questionId; |
||||
|
|
||||
|
@Column(name = "question_content", columnDefinition = "TEXT comment '题目内容快照'") |
||||
|
@TableField("question_content") |
||||
|
private String questionContent; |
||||
|
|
||||
|
@Column(name = "user_answer", columnDefinition = "TEXT comment '考生作答'") |
||||
|
@TableField("user_answer") |
||||
|
private String userAnswer; |
||||
|
|
||||
|
@Column(name = "standard_answer", columnDefinition = "TEXT comment '标准答案/得分点'") |
||||
|
@TableField("standard_answer") |
||||
|
private String standardAnswer; |
||||
|
|
||||
|
/** AI评分(实际得分) */ |
||||
|
@Column(name = "ai_score") |
||||
|
@TableField("ai_score") |
||||
|
@Comment("AI评分") |
||||
|
private Double aiScore; |
||||
|
|
||||
|
/** 命中的得分点index列表 */ |
||||
|
@TableField(value = "hit_points", typeHandler = JacksonTypeHandler.class) |
||||
|
@JdbcTypeCode(SqlTypes.JSON) |
||||
|
@Column(name = "hit_points", columnDefinition = "json comment '命中得分点列表'") |
||||
|
private List<Integer> hitPoints; |
||||
|
|
||||
|
/** 总得分点数 */ |
||||
|
@Column(name = "total_points") |
||||
|
@TableField("total_points") |
||||
|
@Comment("总得分点数") |
||||
|
private Integer totalPoints; |
||||
|
|
||||
|
/** AI评语 */ |
||||
|
@Column(name = "ai_comment", columnDefinition = "varchar(1000) comment 'AI评语'") |
||||
|
@TableField("ai_comment") |
||||
|
private String aiComment; |
||||
|
|
||||
|
/** 请求报文(排查用) */ |
||||
|
@Column(name = "request_body", columnDefinition = "TEXT comment '请求报文'") |
||||
|
@TableField("request_body") |
||||
|
private String requestBody; |
||||
|
|
||||
|
/** 响应报文(排查用) */ |
||||
|
@Column(name = "response_body", columnDefinition = "TEXT comment '响应报文'") |
||||
|
@TableField("response_body") |
||||
|
private String responseBody; |
||||
|
|
||||
|
/** 处理状态:0-处理中,1-成功,2-失败 */ |
||||
|
@Column(name = "status") |
||||
|
@TableField("status") |
||||
|
@Comment("处理状态:0-处理中,1-成功,2-失败") |
||||
|
private Integer status = 0; |
||||
|
|
||||
|
/** 错误信息 */ |
||||
|
@Column(name = "error_msg", columnDefinition = "varchar(500) comment '错误信息'") |
||||
|
@TableField("error_msg") |
||||
|
private String errorMsg; |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
package com.project.exam.domain.service; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.extension.service.IService; |
||||
|
import com.project.exam.domain.entity.AiGradingLogEntity; |
||||
|
|
||||
|
public interface AiGradingLogBaseService extends IService<AiGradingLogEntity> { |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
package com.project.exam.domain.service.impl; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
||||
|
import com.project.exam.domain.entity.AiGradingLogEntity; |
||||
|
import com.project.exam.domain.service.AiGradingLogBaseService; |
||||
|
import com.project.exam.mapper.AiGradingLogMapper; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
@Service |
||||
|
public class AiGradingLogBaseServiceImpl extends ServiceImpl<AiGradingLogMapper, AiGradingLogEntity> implements AiGradingLogBaseService { |
||||
|
} |
||||
@ -0,0 +1,134 @@ |
|||||
|
package com.project.exam.domain.service.strategy; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
||||
|
import com.project.base.domain.exception.BusinessErrorException; |
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
import com.project.exam.domain.entity.ExamRecordEntity; |
||||
|
import com.project.exam.domain.service.ExamRecordBaseService; |
||||
|
import com.project.question.domain.entity.QuestionEntity; |
||||
|
import com.project.task.domain.entity.TaskEntity; |
||||
|
import com.project.task.domain.entity.TaskPaperSnapshotEntity; |
||||
|
import com.project.task.domain.entity.TaskUserEntity; |
||||
|
import com.project.task.domain.service.TaskBaseService; |
||||
|
import com.project.task.domain.service.TaskPaperSnapshotBaseService; |
||||
|
import com.project.task.domain.service.TaskUserBaseService; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.data.redis.core.StringRedisTemplate; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.util.*; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
/** |
||||
|
* 经典套卷组卷策略 |
||||
|
* 从快照表随机抽一个版本,不锁题不补库 |
||||
|
*/ |
||||
|
@Component |
||||
|
@Slf4j |
||||
|
public class ClassicPaperStrategy implements PaperAssemblyStrategy { |
||||
|
|
||||
|
@Autowired |
||||
|
private TaskBaseService taskBaseService; |
||||
|
|
||||
|
@Autowired |
||||
|
private TaskUserBaseService taskUserBaseService; |
||||
|
|
||||
|
@Autowired |
||||
|
private TaskPaperSnapshotBaseService taskPaperSnapshotBaseService; |
||||
|
|
||||
|
@Autowired |
||||
|
private ExamRecordBaseService examRecordBaseService; |
||||
|
|
||||
|
@Autowired |
||||
|
private StringRedisTemplate stringRedisTemplate; |
||||
|
|
||||
|
@Override |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public ExamRecordDTO assemblePaper(Long taskId, String userId) throws Exception { |
||||
|
// 1. 校验用户资格
|
||||
|
TaskUserEntity taskUser = taskUserBaseService.lambdaQuery() |
||||
|
.eq(TaskUserEntity::getTaskId, taskId) |
||||
|
.eq(TaskUserEntity::getUserId, userId) |
||||
|
.one(); |
||||
|
if (taskUser == null) { |
||||
|
throw new BusinessErrorException("您无需参与本场考核"); |
||||
|
} |
||||
|
if (taskUser.getStatus() != null && taskUser.getStatus() == 2) { |
||||
|
throw new BusinessErrorException("您已通过考核,无需重复考试"); |
||||
|
} |
||||
|
|
||||
|
// 2. Redis 防抖(30 秒 TTL)
|
||||
|
String lockKey = String.format("lock:exam:start:%s:%s", taskId, userId); |
||||
|
Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS); |
||||
|
if (Boolean.FALSE.equals(locked)) { |
||||
|
throw new BusinessErrorException("请勿短时间内重复参加考试"); |
||||
|
} |
||||
|
|
||||
|
// 3. 查询任务配置
|
||||
|
TaskEntity task = taskBaseService.getById(taskId); |
||||
|
|
||||
|
// 4. 查询该任务的所有快照
|
||||
|
List<TaskPaperSnapshotEntity> allSnapshots = taskPaperSnapshotBaseService.list( |
||||
|
new LambdaQueryWrapper<TaskPaperSnapshotEntity>() |
||||
|
.eq(TaskPaperSnapshotEntity::getTaskId, taskId)); |
||||
|
if (allSnapshots.isEmpty()) { |
||||
|
throw new BusinessErrorException("该任务暂无可用套卷快照"); |
||||
|
} |
||||
|
|
||||
|
// 5. 获取所有版本的 setIndex,随机选一个
|
||||
|
List<Integer> setIndexes = allSnapshots.stream() |
||||
|
.map(TaskPaperSnapshotEntity::getSetIndex) |
||||
|
.distinct() |
||||
|
.sorted() |
||||
|
.collect(Collectors.toList()); |
||||
|
Integer selectedSetIndex = setIndexes.get(new Random().nextInt(setIndexes.size())); |
||||
|
|
||||
|
// 6. 过滤出该版本的快照,按 sortOrder 排序
|
||||
|
List<TaskPaperSnapshotEntity> selectedSnapshots = allSnapshots.stream() |
||||
|
.filter(s -> selectedSetIndex.equals(s.getSetIndex())) |
||||
|
.sorted(Comparator.comparing(TaskPaperSnapshotEntity::getSortOrder)) |
||||
|
.collect(Collectors.toList()); |
||||
|
|
||||
|
// 7. 构建 QuestionSnapshot 列表
|
||||
|
List<ExamRecordDTO.QuestionSnapshotDTO> snapshotDTOList = new ArrayList<>(); |
||||
|
for (int i = 0; i < selectedSnapshots.size(); i++) { |
||||
|
TaskPaperSnapshotEntity snapshot = selectedSnapshots.get(i); |
||||
|
ExamRecordDTO.QuestionSnapshotDTO dto = new ExamRecordDTO.QuestionSnapshotDTO(); |
||||
|
dto.setQuestionId(snapshot.getQuestionId()); |
||||
|
dto.setIndex(i + 1); |
||||
|
// 从快照的 questionDetail 中拷贝题目内容
|
||||
|
if (snapshot.getQuestionDetail() != null) { |
||||
|
QuestionEntity.QuestionDetail detail = snapshot.getQuestionDetail(); |
||||
|
dto.setQuestionContent(detail.getQuestionContent()); |
||||
|
dto.setType(detail.getType()); |
||||
|
dto.setOptions(detail.getOptions()); |
||||
|
dto.setRightAnswer(detail.getRightAnswer()); |
||||
|
dto.setAnalysis(detail.getAnalysis()); |
||||
|
} |
||||
|
snapshotDTOList.add(dto); |
||||
|
} |
||||
|
|
||||
|
// 8. 构建 ExamRecordDTO(通过 taskUserId 关联)
|
||||
|
ExamRecordDTO examRecordDTO = new ExamRecordDTO(); |
||||
|
examRecordDTO.setTaskUserId(taskUser.getId()); |
||||
|
examRecordDTO.setTaskId(taskId); |
||||
|
examRecordDTO.setTaskName(task.getName()); |
||||
|
examRecordDTO.setAnswerSnapshotDTOList(snapshotDTOList); |
||||
|
|
||||
|
// 9. 保存考试记录(通过 taskUserId 关联)
|
||||
|
ExamRecordEntity recordEntity = new ExamRecordEntity(); |
||||
|
recordEntity.setTaskUserId(taskUser.getId()); |
||||
|
recordEntity.setTaskName(task.getName()); |
||||
|
recordEntity.setStartTime(new Date()); |
||||
|
examRecordBaseService.save(recordEntity); |
||||
|
examRecordDTO.setId(recordEntity.getId()); |
||||
|
|
||||
|
log.info(">>> [经典组卷] 组卷完成, taskId={}, userId={}, selectedSetIndex={}, 题目数={}", |
||||
|
taskId, userId, selectedSetIndex, snapshotDTOList.size()); |
||||
|
|
||||
|
return examRecordDTO; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
package com.project.exam.domain.service.strategy; |
||||
|
|
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
import com.project.exam.domain.service.AssemblePaperDomainService; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
/** |
||||
|
* 生成式组卷策略 |
||||
|
* 直接委托给现有 AssemblePaperDomainServiceImpl,不迁移原有 388 行逻辑 |
||||
|
*/ |
||||
|
@Component |
||||
|
public class GenerativePaperStrategy implements PaperAssemblyStrategy { |
||||
|
|
||||
|
@Autowired |
||||
|
private AssemblePaperDomainService assemblePaperDomainService; |
||||
|
|
||||
|
@Override |
||||
|
public ExamRecordDTO assemblePaper(Long taskId, String userId) throws Exception { |
||||
|
return assemblePaperDomainService.assemblePaper(taskId, userId); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,51 @@ |
|||||
|
package com.project.exam.domain.service.strategy; |
||||
|
|
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
import com.project.task.domain.enums.QuestionTypeEnum; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.util.Arrays; |
||||
|
import java.util.Set; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
/** |
||||
|
* 客观题判分策略 |
||||
|
* 单选题、多选题、判断题:比对答案 |
||||
|
*/ |
||||
|
@Component |
||||
|
public class ObjectiveScoringStrategy implements ScoringStrategy { |
||||
|
|
||||
|
@Override |
||||
|
public boolean supports(Integer questionType) { |
||||
|
// 支持单选、多选、判断
|
||||
|
return QuestionTypeEnum.SINGLE_CHOICE.getValue().equals(questionType) |
||||
|
|| QuestionTypeEnum.MULTIPLE_CHOICE.getValue().equals(questionType) |
||||
|
|| QuestionTypeEnum.TRUE_FALSE.getValue().equals(questionType); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void score(ExamRecordDTO.QuestionSnapshotDTO snapshot, ScoringContext context, Long taskId) { |
||||
|
context.setObjectiveTotal(context.getObjectiveTotal() + 1); |
||||
|
|
||||
|
String userAnswer = snapshot.getUserAnswer(); |
||||
|
String rightAnswer = snapshot.getRightAnswer(); |
||||
|
|
||||
|
boolean isRight = false; |
||||
|
if (userAnswer != null && rightAnswer != null) { |
||||
|
if (QuestionTypeEnum.MULTIPLE_CHOICE.getValue().equals(snapshot.getType())) { |
||||
|
// 多选题:集合比较(忽略顺序)
|
||||
|
Set<String> userSet = Arrays.stream(userAnswer.split(",")).map(String::trim).collect(Collectors.toSet()); |
||||
|
Set<String> rightSet = Arrays.stream(rightAnswer.split(",")).map(String::trim).collect(Collectors.toSet()); |
||||
|
isRight = userSet.equals(rightSet); |
||||
|
} else { |
||||
|
// 单选题、判断题:直接比较
|
||||
|
isRight = userAnswer.trim().equalsIgnoreCase(rightAnswer.trim()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
snapshot.setIsRight(isRight); |
||||
|
if (isRight) { |
||||
|
context.setRightCount(context.getRightCount() + 1); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
package com.project.exam.domain.service.strategy; |
||||
|
|
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
|
||||
|
/** |
||||
|
* 组卷策略接口 |
||||
|
* 生成式模式:A-Res 算法动态抽卷 + 锁题 + 水位线补库 |
||||
|
* 经典模式:从快照表随机抽一个版本,不锁题不补库 |
||||
|
*/ |
||||
|
public interface PaperAssemblyStrategy { |
||||
|
|
||||
|
/** |
||||
|
* 组卷 |
||||
|
* @param taskId 考试任务ID |
||||
|
* @param userId 考生ID |
||||
|
* @return 考试记录DTO(含题目快照) |
||||
|
*/ |
||||
|
ExamRecordDTO assemblePaper(Long taskId, String userId) throws Exception; |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
package com.project.exam.domain.service.strategy; |
||||
|
|
||||
|
import com.project.task.domain.enums.ExamModeEnum; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
/** |
||||
|
* 组卷策略工厂 |
||||
|
* 根据 examMode 分发到对应的策略实现 |
||||
|
*/ |
||||
|
@Component |
||||
|
public class PaperAssemblyStrategyFactory { |
||||
|
|
||||
|
@Autowired |
||||
|
private GenerativePaperStrategy generativePaperStrategy; |
||||
|
|
||||
|
@Autowired |
||||
|
private ClassicPaperStrategy classicPaperStrategy; |
||||
|
|
||||
|
/** |
||||
|
* 获取组卷策略 |
||||
|
* @param examMode 考试模式:0-生成式,1-经典套题 |
||||
|
* @return 对应的策略实现 |
||||
|
*/ |
||||
|
public PaperAssemblyStrategy getStrategy(Integer examMode) { |
||||
|
if (ExamModeEnum.CLASSIC.getValue().equals(examMode)) { |
||||
|
return classicPaperStrategy; |
||||
|
} |
||||
|
return generativePaperStrategy; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
package com.project.exam.domain.service.strategy; |
||||
|
|
||||
|
import com.project.task.domain.entity.TaskEntity; |
||||
|
import lombok.Data; |
||||
|
|
||||
|
/** |
||||
|
* 判分上下文 |
||||
|
* 在判分过程中累积客观题得分,最终与简答题得分汇总 |
||||
|
*/ |
||||
|
@Data |
||||
|
public class ScoringContext { |
||||
|
|
||||
|
/** 任务配置 */ |
||||
|
private TaskEntity task; |
||||
|
|
||||
|
/** 客观题累计得分 */ |
||||
|
private Double objectiveScore = 0.0; |
||||
|
|
||||
|
/** 客观题答对数 */ |
||||
|
private int rightCount = 0; |
||||
|
|
||||
|
/** 客观题总数 */ |
||||
|
private int objectiveTotal = 0; |
||||
|
|
||||
|
/** 简答题累计得分 */ |
||||
|
private Double subjectiveScore = 0.0; |
||||
|
|
||||
|
/** 最终总分 */ |
||||
|
public Double getTotalScore() { |
||||
|
return objectiveScore + subjectiveScore; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,26 @@ |
|||||
|
package com.project.exam.domain.service.strategy; |
||||
|
|
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
|
||||
|
/** |
||||
|
* 判分策略接口 |
||||
|
* 客观题:比对答案算分 |
||||
|
* 主观题(简答题):调AI阅卷 |
||||
|
*/ |
||||
|
public interface ScoringStrategy { |
||||
|
|
||||
|
/** |
||||
|
* 判断该策略是否支持此题型 |
||||
|
* @param questionType 题型 |
||||
|
* @return 是否支持 |
||||
|
*/ |
||||
|
boolean supports(Integer questionType); |
||||
|
|
||||
|
/** |
||||
|
* 对单题判分 |
||||
|
* @param snapshot 题目快照(含用户答案) |
||||
|
* @param context 判分上下文(累积得分) |
||||
|
* @param taskId 任务ID(用于获取分值配置) |
||||
|
*/ |
||||
|
void score(ExamRecordDTO.QuestionSnapshotDTO snapshot, ScoringContext context, Long taskId); |
||||
|
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
package com.project.exam.domain.service.strategy; |
||||
|
|
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* 判分策略工厂 |
||||
|
* 根据题型分发到对应的策略实现 |
||||
|
*/ |
||||
|
@Component |
||||
|
public class ScoringStrategyFactory { |
||||
|
|
||||
|
@Autowired |
||||
|
private List<ScoringStrategy> strategies; |
||||
|
|
||||
|
/** |
||||
|
* 获取判分策略 |
||||
|
* @param questionType 题型 |
||||
|
* @return 对应的策略实现 |
||||
|
*/ |
||||
|
public ScoringStrategy getStrategy(Integer questionType) { |
||||
|
return strategies.stream() |
||||
|
.filter(s -> s.supports(questionType)) |
||||
|
.findFirst() |
||||
|
.orElseThrow(() -> new RuntimeException("不支持的题型: " + questionType)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,100 @@ |
|||||
|
package com.project.exam.domain.service.strategy; |
||||
|
|
||||
|
import com.project.exam.domain.dto.ExamRecordDTO; |
||||
|
import com.project.exam.domain.entity.AiGradingLogEntity; |
||||
|
import com.project.exam.domain.service.AiGradingLogBaseService; |
||||
|
import com.project.interaction.domain.dto.AiScoringRequestDTO; |
||||
|
import com.project.interaction.domain.dto.AiScoringResponseDTO; |
||||
|
import com.project.interaction.domain.service.PostToAiScoringDomainService; |
||||
|
import com.project.question.domain.entity.QuestionEntity; |
||||
|
import com.project.task.domain.enums.QuestionTypeEnum; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* 简答题判分策略 |
||||
|
* 调用AI阅卷,多点得分制:命中得分点数/总得分点数 × 题目分值 |
||||
|
*/ |
||||
|
@Component |
||||
|
@Slf4j |
||||
|
public class SubjectiveScoringStrategy implements ScoringStrategy { |
||||
|
|
||||
|
@Autowired |
||||
|
private PostToAiScoringDomainService postToAiScoringDomainService; |
||||
|
|
||||
|
@Autowired |
||||
|
private AiGradingLogBaseService aiGradingLogBaseService; |
||||
|
|
||||
|
@Override |
||||
|
public boolean supports(Integer questionType) { |
||||
|
return QuestionTypeEnum.SHORT_ANSWER.getValue().equals(questionType); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void score(ExamRecordDTO.QuestionSnapshotDTO snapshot, ScoringContext context, Long taskId) { |
||||
|
Long questionId = snapshot.getQuestionId(); |
||||
|
|
||||
|
// 构建AI阅卷请求
|
||||
|
AiScoringRequestDTO request = new AiScoringRequestDTO(); |
||||
|
request.setQuestionId(questionId); |
||||
|
request.setQuestionContent(snapshot.getQuestionContent()); |
||||
|
request.setUserAnswer(snapshot.getUserAnswer()); |
||||
|
|
||||
|
// 构建得分点列表(从快照的 options 中获取,简答题的 options 存储得分点)
|
||||
|
// 注意:得分点存储在 QuestionDetail.scoringPoints 中,需要从快照中获取
|
||||
|
// 这里先用空列表,后续从快照中获取
|
||||
|
request.setScoringPoints(new ArrayList<>()); |
||||
|
|
||||
|
// 创建判分日志
|
||||
|
AiGradingLogEntity logEntity = new AiGradingLogEntity(); |
||||
|
logEntity.setExamRecordId(context.getTask().getId()); // 临时,后续需要传入真正的 examRecordId
|
||||
|
logEntity.setQuestionId(questionId); |
||||
|
logEntity.setQuestionContent(snapshot.getQuestionContent()); |
||||
|
logEntity.setUserAnswer(snapshot.getUserAnswer()); |
||||
|
logEntity.setStatus(0); // 处理中
|
||||
|
|
||||
|
try { |
||||
|
// 调用AI阅卷
|
||||
|
AiScoringResponseDTO response = postToAiScoringDomainService.requestAiScoring(request); |
||||
|
|
||||
|
if (response.getStatus() == 1 && response.getHitPoints() != null) { |
||||
|
// 计算得分:命中数 / 总数 × 题目分值
|
||||
|
int hitCount = response.getHitPoints().size(); |
||||
|
int totalCount = response.getTotalPoints(); |
||||
|
double questionScore = snapshot.getScore() != null ? snapshot.getScore() : 0.0; |
||||
|
double aiScore = totalCount > 0 ? questionScore * hitCount / totalCount : 0.0; |
||||
|
|
||||
|
// 更新快照
|
||||
|
snapshot.setAiScore(aiScore); |
||||
|
snapshot.setAiComment(response.getComment()); |
||||
|
snapshot.setHitPoints(response.getHitPoints()); |
||||
|
snapshot.setUserScore(aiScore); |
||||
|
|
||||
|
// 累加简答题得分
|
||||
|
context.setSubjectiveScore(context.getSubjectiveScore() + aiScore); |
||||
|
|
||||
|
// 更新日志
|
||||
|
logEntity.setAiScore(aiScore); |
||||
|
logEntity.setHitPoints(response.getHitPoints()); |
||||
|
logEntity.setTotalPoints(totalCount); |
||||
|
logEntity.setAiComment(response.getComment()); |
||||
|
logEntity.setStatus(1); // 成功
|
||||
|
} else { |
||||
|
log.error(">>> [AI阅卷] 评分失败, questionId={}, error={}", questionId, response.getErrorMsg()); |
||||
|
logEntity.setStatus(2); // 失败
|
||||
|
logEntity.setErrorMsg(response.getErrorMsg()); |
||||
|
} |
||||
|
} catch (Exception e) { |
||||
|
log.error(">>> [AI阅卷] 异常, questionId={}", questionId, e); |
||||
|
logEntity.setStatus(2); // 失败
|
||||
|
logEntity.setErrorMsg(e.getMessage()); |
||||
|
} |
||||
|
|
||||
|
// 保存日志
|
||||
|
aiGradingLogBaseService.save(logEntity); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
package com.project.exam.mapper; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import com.project.exam.domain.entity.AiGradingLogEntity; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
|
||||
|
@Mapper |
||||
|
public interface AiGradingLogMapper extends BaseMapper<AiGradingLogEntity> { |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
package com.project.interaction.domain.dto; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* AI阅卷请求 DTO |
||||
|
* 发送给算法服务,请求对简答题进行评分 |
||||
|
*/ |
||||
|
@Data |
||||
|
public class AiScoringRequestDTO { |
||||
|
|
||||
|
/** 考试记录ID */ |
||||
|
private Long examRecordId; |
||||
|
|
||||
|
/** 题目ID */ |
||||
|
private Long questionId; |
||||
|
|
||||
|
/** 题目内容 */ |
||||
|
private String questionContent; |
||||
|
|
||||
|
/** 考生作答 */ |
||||
|
private String userAnswer; |
||||
|
|
||||
|
/** 得分点列表 */ |
||||
|
private List<ScoringPoint> scoringPoints; |
||||
|
|
||||
|
@Data |
||||
|
public static class ScoringPoint { |
||||
|
/** 得分点序号 */ |
||||
|
private Integer index; |
||||
|
/** 得分点内容 */ |
||||
|
private String content; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
package com.project.interaction.domain.dto; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
/** |
||||
|
* AI阅卷响应 DTO |
||||
|
* 算法服务返回的评分结果 |
||||
|
*/ |
||||
|
@Data |
||||
|
public class AiScoringResponseDTO { |
||||
|
|
||||
|
/** 命中的得分点index列表 */ |
||||
|
private List<Integer> hitPoints; |
||||
|
|
||||
|
/** 总得分点数 */ |
||||
|
private Integer totalPoints; |
||||
|
|
||||
|
/** AI评语 */ |
||||
|
private String comment; |
||||
|
|
||||
|
/** 处理状态:1-成功,2-失败 */ |
||||
|
private Integer status; |
||||
|
|
||||
|
/** 错误信息(失败时有值) */ |
||||
|
private String errorMsg; |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
package com.project.interaction.domain.service; |
||||
|
|
||||
|
import com.project.interaction.domain.dto.AiScoringRequestDTO; |
||||
|
import com.project.interaction.domain.dto.AiScoringResponseDTO; |
||||
|
|
||||
|
/** |
||||
|
* 调用算法服务进行AI阅卷(同步调用) |
||||
|
* 简答题数量少(1-3道),同步等待返回 |
||||
|
*/ |
||||
|
public interface PostToAiScoringDomainService { |
||||
|
|
||||
|
/** |
||||
|
* 请求AI阅卷 |
||||
|
* @param request 请求参数 |
||||
|
* @return 评分结果 |
||||
|
*/ |
||||
|
AiScoringResponseDTO requestAiScoring(AiScoringRequestDTO request); |
||||
|
} |
||||
@ -0,0 +1,63 @@ |
|||||
|
package com.project.interaction.domain.service.impl; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.project.interaction.domain.dto.AiScoringRequestDTO; |
||||
|
import com.project.interaction.domain.dto.AiScoringResponseDTO; |
||||
|
import com.project.interaction.domain.service.PostToAiScoringDomainService; |
||||
|
import jakarta.annotation.Resource; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Value; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.web.reactive.function.client.WebClient; |
||||
|
|
||||
|
import java.time.Duration; |
||||
|
|
||||
|
/** |
||||
|
* 调用算法服务进行AI阅卷 |
||||
|
* 同步调用,60秒超时 |
||||
|
* 算法服务API待确认,URL为占位实现 |
||||
|
*/ |
||||
|
@Service |
||||
|
@Slf4j |
||||
|
public class PostToAiScoringDomainServiceImpl implements PostToAiScoringDomainService { |
||||
|
|
||||
|
@Resource(name = "algorithmWebClient") |
||||
|
private WebClient algorithmWebClient; |
||||
|
|
||||
|
/** 算法服务阅卷路径(待确认) */ |
||||
|
@Value("${algo.scoringUrl:/ai-scoring}") |
||||
|
private String scoringUrl; |
||||
|
|
||||
|
private final ObjectMapper objectMapper = new ObjectMapper(); |
||||
|
|
||||
|
@Override |
||||
|
public AiScoringResponseDTO requestAiScoring(AiScoringRequestDTO request) { |
||||
|
try { |
||||
|
log.info(">>> [AI阅卷] 正在请求AI阅卷, questionId={}, examRecordId={}", |
||||
|
request.getQuestionId(), request.getExamRecordId()); |
||||
|
|
||||
|
String responseBody = algorithmWebClient.post() |
||||
|
.uri(scoringUrl) |
||||
|
.bodyValue(request) |
||||
|
.retrieve() |
||||
|
.bodyToMono(String.class) |
||||
|
.timeout(Duration.ofSeconds(60)) |
||||
|
.block(); |
||||
|
|
||||
|
log.info(">>> [AI阅卷] 算法服务返回, questionId={}, response={}", |
||||
|
request.getQuestionId(), responseBody); |
||||
|
|
||||
|
// 解析响应
|
||||
|
AiScoringResponseDTO response = objectMapper.readValue(responseBody, AiScoringResponseDTO.class); |
||||
|
response.setStatus(1); // 成功
|
||||
|
return response; |
||||
|
|
||||
|
} catch (Exception e) { |
||||
|
log.error(">>> [AI阅卷] 算法服务调用异常, questionId={}", request.getQuestionId(), e); |
||||
|
AiScoringResponseDTO errorResponse = new AiScoringResponseDTO(); |
||||
|
errorResponse.setStatus(2); // 失败
|
||||
|
errorResponse.setErrorMsg(e.getMessage()); |
||||
|
return errorResponse; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue