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