feat: 任务评阅教师触发AI评分与AI评语生成

- 教师可对单个/批量学生异步发起AI评分(复用学生端助评链路),评阅面板轮询展示AI分
- 新增 POST /api/weight/taskReview/ai-comment:基于该生AI评分报告+AI助学报告
  生成10-50字中文教师评语(超长按句子边界截断),供评阅弹窗自动填入
- student_training_answer 新增 teacher_comment 列(启动自动补列),保存评分支持携带评语
- pom 固定 lombok 1.18.32 修复 JDK21 构建兼容
main
chenyuan 9 hours ago
parent 99c7167a0a
commit 0e9ddeda21

@ -0,0 +1,3 @@
-- 教师任务评阅评语字段;应用启动时也会由 schema initializer 自动补列。
ALTER TABLE student_training_answer
ADD COLUMN teacher_comment varchar(1000) NULL AFTER teacher_score;

@ -140,6 +140,9 @@
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<!-- Spring Boot 2.7.12 manages an older Lombok release that
is incompatible with JDK 21 (JCImport.qualid was removed). -->
<version>1.18.32</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>

@ -18,6 +18,7 @@ public class StudentTrainingAnswerTeacherScoreSchemaInitializer {
addColumnIfMissing("teacher_score", "decimal(5,2) NULL");
addColumnIfMissing("teacher_scored_by", "varchar(64) NULL");
addColumnIfMissing("teacher_scored_at", "datetime NULL");
addColumnIfMissing("teacher_comment", "varchar(1000) NULL");
}
private void addColumnIfMissing(String columnName, String definition) {

@ -56,8 +56,10 @@ public class AiTrainingEvaluationController {
@ApiOperation("Generate AI assessment")
public ResultEntity<AiTrainingEvaluationView> assessment(@PathVariable String taskKey, HttpServletRequest request) {
try {
return new ResultEntity<>(HttpStatus.OK, "AI assessment generated",
aiTrainingEvaluationService.generateAssessment(taskKey, currentUser(request)));
AiTrainingEvaluationView view = aiTrainingEvaluationService.startAssessment(taskKey, currentUser(request));
// 兼容旧版服务实现/测试替身;正式实现始终立即返回 PROCESSING 状态。
if (view == null) view = aiTrainingEvaluationService.generateAssessment(taskKey, currentUser(request));
return new ResultEntity<>(HttpStatus.OK, "AI assessment submitted", view);
} catch (ServiceException e) {
return new ResultEntity<>(e.getCode(), e.getMessage());
}

@ -14,25 +14,35 @@ import com.sztzjy.linkCommerce.mapper.StudentTrainingAnswerMapper;
import com.sztzjy.linkCommerce.mapper.TaskAllocationMapper;
import com.sztzjy.linkCommerce.mapper.TeachingClassStudentMapper;
import com.sztzjy.linkCommerce.mapper.TrainingTaskMapper;
import com.sztzjy.linkCommerce.mapper.TrainingTaskClassConfigMapper;
import com.sztzjy.linkCommerce.mapper.UserinfoMapper;
import com.sztzjy.linkCommerce.mapper.WeightMapper;
import com.sztzjy.linkCommerce.entity.dto.*;
import com.sztzjy.linkCommerce.service.ScoreRankService;
import com.sztzjy.linkCommerce.service.SchoolDefaultTaskService;
import com.sztzjy.linkCommerce.service.TeacherScoreService;
import com.sztzjy.linkCommerce.service.AiTrainingEvaluationService;
import com.sztzjy.linkCommerce.util.ResultEntity;
import com.sztzjy.linkCommerce.util.excel.FilePortUtil;
import com.sztzjy.linkCommerce.util.file.IFileUtil;
import com.sztzjy.linkCommerce.util.pdfUtils.PDFConvertUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.math.BigDecimal;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.File;
import java.nio.file.Files;
import java.util.*;
import java.util.stream.Collectors;
@ -58,6 +68,8 @@ public class TeaScoreController {
@Autowired(required = false)
TrainingTaskMapper trainingTaskMapper;
@Autowired(required = false)
TrainingTaskClassConfigMapper trainingTaskClassConfigMapper;
@Autowired(required = false)
StudentTrainingAnswerMapper studentTrainingAnswerMapper;
@Autowired(required = false)
SchoolDefaultTaskService schoolDefaultTaskService;
@ -65,6 +77,78 @@ public class TeaScoreController {
UserinfoMapper userinfoMapper;
@Autowired
TeacherScoreService teacherScoreService;
@Autowired
IFileUtil fileUtil;
@Autowired
AiTrainingEvaluationService aiTrainingEvaluationService;
@PostMapping("/taskReview/ai-assessment")
@ApiOperation("异步发起单个任务AI评分")
public ResultEntity<AiTrainingEvaluationView> startTaskAiAssessment(@RequestParam String classId,
@RequestParam String taskId,
@RequestParam String studentUserId,
HttpServletRequest request) {
try {
JwtUser teacher = currentUser(request);
teacherScoreService.requireOwnedClass(teacher, classId);
return new ResultEntity<>(HttpStatus.OK, "AI评分任务已提交",
aiTrainingEvaluationService.startAssessmentForTeacher(classId, taskId, studentUserId, teacher));
} catch (com.sztzjy.linkCommerce.config.exception.handler.ServiceException e) {
return new ResultEntity<>(e.getCode(), e.getMessage());
}
}
@GetMapping("/taskReview/ai-assessment")
@ApiOperation("查询任务AI评分状态")
public ResultEntity<AiTrainingEvaluationView> getTaskAiAssessment(@RequestParam String classId,
@RequestParam String taskId,
@RequestParam String studentUserId,
HttpServletRequest request) {
try {
JwtUser teacher = currentUser(request);
teacherScoreService.requireAccessibleClass(teacher, classId);
return new ResultEntity<>(HttpStatus.OK, "查询成功",
aiTrainingEvaluationService.getForTeacher(classId, taskId, studentUserId, teacher));
} catch (com.sztzjy.linkCommerce.config.exception.handler.ServiceException e) {
return new ResultEntity<>(e.getCode(), e.getMessage());
}
}
@PostMapping("/taskReview/ai-comment")
@ApiOperation("基于AI评分与助学报告生成10-50字教师评语")
public ResultEntity<Map<String, String>> generateTaskAiComment(@RequestParam String classId,
@RequestParam String taskId,
@RequestParam String studentUserId,
HttpServletRequest request) {
try {
JwtUser teacher = currentUser(request);
teacherScoreService.requireAccessibleClass(teacher, classId);
return new ResultEntity<>(HttpStatus.OK, "评语生成成功",
aiTrainingEvaluationService.generateCommentForTeacher(classId, taskId, studentUserId, teacher));
} catch (com.sztzjy.linkCommerce.config.exception.handler.ServiceException e) {
return new ResultEntity<>(e.getCode(), e.getMessage());
}
}
@PostMapping("/taskReview/ai-assessment/batch")
@ApiOperation("异步发起批量任务AI评分")
public ResultEntity<List<AiTrainingEvaluationView>> startBatchTaskAiAssessment(@RequestBody BatchTaskAiAssessmentRequest body,
HttpServletRequest request) {
try {
JwtUser teacher = currentUser(request);
teacherScoreService.requireOwnedClass(teacher, body == null ? null : body.getClassId());
if (body == null || body.getStudentUserIds() == null || body.getStudentUserIds().isEmpty()) {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "请选择学生");
}
List<AiTrainingEvaluationView> result = new ArrayList<>();
for (String studentUserId : body.getStudentUserIds()) {
result.add(aiTrainingEvaluationService.startAssessmentForTeacher(body.getClassId(), body.getTaskId(), studentUserId, teacher));
}
return new ResultEntity<>(HttpStatus.OK, "批量AI评分任务已提交", result);
} catch (com.sztzjy.linkCommerce.config.exception.handler.ServiceException e) {
return new ResultEntity<>(e.getCode(), e.getMessage());
}
}
@GetMapping("/ownedTeachingClasses")
@ApiOperation("教师本人教学班列表")
@ -130,7 +214,8 @@ public class TeaScoreController {
public ResultEntity<?> saveTaskTeacherScore(@RequestParam String classId,
@RequestParam String studentUserId,
@RequestParam String taskId,
@RequestParam BigDecimal teacherScore,
@RequestParam(required = false) BigDecimal teacherScore,
@RequestParam(required = false) String teacherComment,
HttpServletRequest request) {
try {
JwtUser teacher = currentUser(request);
@ -148,9 +233,10 @@ public class TeaScoreController {
return new ResultEntity<>(HttpStatus.BAD_REQUEST, "教师评分必须为0至100之间的数值");
}
Date scoredAt = new Date();
studentTrainingAnswerMapper.updateTeacherScore(answer.getId(), teacherScore.setScale(2, BigDecimal.ROUND_HALF_UP),
teacher.getUserId(), scoredAt);
studentTrainingAnswerMapper.updateTeacherScoreWithComment(answer.getId(), teacherScore.setScale(2, BigDecimal.ROUND_HALF_UP),
teacher.getUserId(), scoredAt, StringUtils.left(StringUtils.trimToEmpty(teacherComment), 1000));
answer.setTeacherScore(teacherScore.setScale(2, BigDecimal.ROUND_HALF_UP));
answer.setTeacherComment(StringUtils.left(StringUtils.trimToEmpty(teacherComment), 1000));
answer.setTeacherScoredBy(teacher.getUserId());
answer.setTeacherScoredAt(scoredAt);
return new ResultEntity<>(HttpStatus.OK, "任务评分保存成功", answer);
@ -174,6 +260,42 @@ public class TeaScoreController {
}
}
@GetMapping("/taskReview/reference-answer/preview")
@ApiOperation("在线预览任务参考答案")
public ResponseEntity<byte[]> previewTaskReferenceAnswer(@RequestParam String classId,
@RequestParam String taskId,
HttpServletRequest request) {
try {
teacherScoreService.requireAccessibleClass(currentUser(request), classId);
TrainingTask task = trainingTaskMapper.selectByPrimaryKey(taskId);
if (task == null) return ResponseEntity.notFound().build();
TrainingTaskClassConfig classConfig = trainingTaskClassConfigMapper == null ? null
: trainingTaskClassConfigMapper.selectByTeachingClassAndTaskKey(classId, task.getTaskKey());
String referenceUrl = classConfig != null && StringUtils.isNotBlank(classConfig.getReferenceAnswerUrl())
? classConfig.getReferenceAnswerUrl() : task == null ? null : task.getReferenceAnswerUrl();
String referenceName = classConfig != null && StringUtils.isNotBlank(classConfig.getReferenceAnswerName())
? classConfig.getReferenceAnswerName() : task == null ? null : task.getReferenceAnswerName();
if (StringUtils.isBlank(referenceUrl)) {
return ResponseEntity.notFound().build();
}
String name = StringUtils.defaultIfBlank(referenceName, referenceUrl);
String path = referenceUrl;
int slash = path.indexOf("/file/");
if (slash >= 0) path = path.substring(slash + 1);
File source = new File(fileUtil.getFullPath(path));
if (!source.exists() || !PDFConvertUtil.isCreatePdf(name)) return ResponseEntity.notFound().build();
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (InputStream input = Files.newInputStream(source.toPath())) {
if (!PDFConvertUtil.convertToPDFByFileNameSuffix(name, input, output)) return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
}
return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).header("Content-Disposition", "inline; filename=reference-answer.pdf").body(output.toByteArray());
} catch (com.sztzjy.linkCommerce.config.exception.handler.ServiceException e) {
return ResponseEntity.status(e.getCode()).build();
} catch (RuntimeException | java.io.IOException e) {
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
}
}
@PostMapping("/batchSaveTaskTeacherScore")
@ApiOperation("批量保存教师任务评分")
@Transactional
@ -189,7 +311,8 @@ public class TeaScoreController {
BigDecimal score = body.getTeacherScore().setScale(2, BigDecimal.ROUND_HALF_UP);
Date scoredAt = new Date();
for (StudentTrainingAnswer answer : answers) {
studentTrainingAnswerMapper.updateTeacherScore(answer.getId(), score, teacher.getUserId(), scoredAt);
studentTrainingAnswerMapper.updateTeacherScoreWithComment(answer.getId(), score, teacher.getUserId(), scoredAt,
StringUtils.left(StringUtils.trimToEmpty(body.getTeacherComment()), 1000));
}
return new ResultEntity<>(HttpStatus.OK, "批量任务评分保存成功", body.getStudentUserIds());
} catch (com.sztzjy.linkCommerce.config.exception.handler.ServiceException e) {
@ -669,7 +792,7 @@ public class TeaScoreController {
if (task == null || StringUtils.isBlank(task.getId()) || !seenTaskIds.add(task.getId())) {
continue;
}
TeacherTaskReviewOptionDTO option = reviewOption(task);
TeacherTaskReviewOptionDTO option = reviewOption(task, classId);
allTasks.add(option);
if (StringUtils.isNotBlank(option.getProjectName())) {
projectNames.add(option.getProjectName());
@ -703,13 +826,19 @@ public class TeaScoreController {
return page;
}
private TeacherTaskReviewOptionDTO reviewOption(TrainingTask task) {
private TeacherTaskReviewOptionDTO reviewOption(TrainingTask task, String classId) {
TeacherTaskReviewOptionDTO option = new TeacherTaskReviewOptionDTO();
option.setTaskId(task.getId());
option.setTaskKey(task.getTaskKey());
option.setProjectName(task.getProjectName());
option.setTaskName(task.getTaskName());
option.setSteps(task.getSteps());
TrainingTaskClassConfig classConfig = trainingTaskClassConfigMapper == null ? null
: trainingTaskClassConfigMapper.selectByTeachingClassAndTaskKey(classId, task.getTaskKey());
option.setReferenceAnswerName(classConfig != null && StringUtils.isNotBlank(classConfig.getReferenceAnswerName())
? classConfig.getReferenceAnswerName() : task.getReferenceAnswerName());
option.setReferenceAnswerUrl(classConfig != null && StringUtils.isNotBlank(classConfig.getReferenceAnswerUrl())
? classConfig.getReferenceAnswerUrl() : task.getReferenceAnswerUrl());
return option;
}
@ -725,6 +854,7 @@ public class TeaScoreController {
if (answer != null) {
row.setAiAssessmentScore(answer.getAiAssessmentScore());
row.setTeacherScore(answer.getTeacherScore());
row.setTeacherComment(answer.getTeacherComment());
row.setStep1Answer(answer.getStep1Answer());
row.setStep2Answer(answer.getStep2Answer());
row.setStep3Answer(answer.getStep3Answer());

@ -41,8 +41,10 @@ public class TrainingTaskController {
HttpServletRequest request) {
JwtUser user = TokenProvider.getJWTUser(request);
if (isStudent(user)) {
List<TrainingTask> studentTasks = trainingTaskService.listForStudent(user, enabledOnly);
studentTasks.forEach(this::removeReferenceAnswerForStudent);
return new ResultEntity<>(HttpStatus.OK, "Query success",
trainingTaskService.listForStudent(user, enabledOnly));
studentTasks);
}
return new ResultEntity<>(HttpStatus.OK, "Query success",
trainingTaskService.listDefaults(projectName, taskName, enabledOnly));
@ -69,6 +71,9 @@ public class TrainingTaskController {
if (task == null) {
return new ResultEntity<>(HttpStatus.OK, "Training task not configured", null);
}
if (isStudent(user)) {
removeReferenceAnswerForStudent(task);
}
return new ResultEntity<>(HttpStatus.OK, "Query success", task);
}
@ -240,6 +245,13 @@ public class TrainingTaskController {
return user != null && user.getRoleId() == 4;
}
/** 参考答案属于教师评阅资料,学生任务接口不得返回文件名或地址。 */
private void removeReferenceAnswerForStudent(TrainingTask task) {
if (task == null) return;
task.setReferenceAnswerName(null);
task.setReferenceAnswerUrl(null);
}
private JwtUser requireAdmin(HttpServletRequest request) {
JwtUser user = TokenProvider.getJWTUser(request);
if (user == null || user.getRoleId() != 1) {

@ -53,6 +53,9 @@ public class StudentTrainingAnswer {
@ApiModelProperty("教师任务评分")
private BigDecimal teacherScore;
@ApiModelProperty("教师评语")
private String teacherComment;
@ApiModelProperty("教师评分人")
private String teacherScoredBy;
@ -197,6 +200,8 @@ public class StudentTrainingAnswer {
public void setTeacherScoredBy(String teacherScoredBy) { this.teacherScoredBy = teacherScoredBy == null ? null : teacherScoredBy.trim(); }
public Date getTeacherScoredAt() { return teacherScoredAt; }
public void setTeacherScoredAt(Date teacherScoredAt) { this.teacherScoredAt = teacherScoredAt; }
public String getTeacherComment() { return teacherComment; }
public void setTeacherComment(String teacherComment) { this.teacherComment = teacherComment == null ? null : teacherComment.trim(); }
public String getSaveAction() {
return saveAction;

@ -9,6 +9,7 @@ public class AiTrainingEvaluationView {
private Date helpCompletedAt;
private String assessmentStatus;
private Integer assessmentScore;
private String assessmentComment;
private String assessmentReportJson;
private Date assessmentCompletedAt;
@ -22,6 +23,8 @@ public class AiTrainingEvaluationView {
public void setAssessmentStatus(String assessmentStatus) { this.assessmentStatus = assessmentStatus; }
public Integer getAssessmentScore() { return assessmentScore; }
public void setAssessmentScore(Integer assessmentScore) { this.assessmentScore = assessmentScore; }
public String getAssessmentComment() { return assessmentComment; }
public void setAssessmentComment(String assessmentComment) { this.assessmentComment = assessmentComment; }
public String getAssessmentReportJson() { return assessmentReportJson; }
public void setAssessmentReportJson(String assessmentReportJson) { this.assessmentReportJson = assessmentReportJson; }
public Date getAssessmentCompletedAt() { return assessmentCompletedAt; }

@ -0,0 +1,15 @@
package com.sztzjy.linkCommerce.entity.dto;
import java.util.List;
public class BatchTaskAiAssessmentRequest {
private String classId;
private String taskId;
private List<String> studentUserIds;
public String getClassId() { return classId; }
public void setClassId(String classId) { this.classId = classId; }
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
public List<String> getStudentUserIds() { return studentUserIds; }
public void setStudentUserIds(List<String> studentUserIds) { this.studentUserIds = studentUserIds; }
}

@ -8,6 +8,7 @@ public class BatchTaskTeacherScoreRequest {
private String taskId;
private List<String> studentUserIds;
private BigDecimal teacherScore;
private String teacherComment;
public String getClassId() { return classId; }
public void setClassId(String classId) { this.classId = classId; }
@ -17,4 +18,6 @@ public class BatchTaskTeacherScoreRequest {
public void setStudentUserIds(List<String> studentUserIds) { this.studentUserIds = studentUserIds; }
public BigDecimal getTeacherScore() { return teacherScore; }
public void setTeacherScore(BigDecimal teacherScore) { this.teacherScore = teacherScore; }
public String getTeacherComment() { return teacherComment; }
public void setTeacherComment(String teacherComment) { this.teacherComment = teacherComment; }
}

@ -6,6 +6,8 @@ public class TeacherTaskReviewOptionDTO {
private String taskName;
private String taskKey;
private String steps;
private String referenceAnswerName;
private String referenceAnswerUrl;
public String getTaskId() { return taskId; }
public void setTaskId(String taskId) { this.taskId = taskId; }
@ -17,4 +19,8 @@ public class TeacherTaskReviewOptionDTO {
public void setTaskKey(String taskKey) { this.taskKey = taskKey; }
public String getSteps() { return steps; }
public void setSteps(String steps) { this.steps = steps; }
public String getReferenceAnswerName() { return referenceAnswerName; }
public void setReferenceAnswerName(String referenceAnswerName) { this.referenceAnswerName = referenceAnswerName; }
public String getReferenceAnswerUrl() { return referenceAnswerUrl; }
public void setReferenceAnswerUrl(String referenceAnswerUrl) { this.referenceAnswerUrl = referenceAnswerUrl; }
}

@ -11,6 +11,7 @@ public class TeacherTaskReviewStudentDTO {
private Boolean scorable;
private Integer aiAssessmentScore;
private BigDecimal teacherScore;
private String teacherComment;
private String step1Answer;
private String step2Answer;
private String step3Answer;
@ -33,6 +34,8 @@ public class TeacherTaskReviewStudentDTO {
public void setAiAssessmentScore(Integer aiAssessmentScore) { this.aiAssessmentScore = aiAssessmentScore; }
public BigDecimal getTeacherScore() { return teacherScore; }
public void setTeacherScore(BigDecimal teacherScore) { this.teacherScore = teacherScore; }
public String getTeacherComment() { return teacherComment; }
public void setTeacherComment(String teacherComment) { this.teacherComment = teacherComment; }
public String getStep1Answer() { return step1Answer; }
public void setStep1Answer(String step1Answer) { this.step1Answer = step1Answer; }
public String getStep2Answer() { return step2Answer; }

@ -32,6 +32,12 @@ public interface StudentTrainingAnswerMapper {
@Param("teacherUserId") String teacherUserId,
@Param("scoredAt") Date scoredAt);
int updateTeacherScoreWithComment(@Param("id") String id,
@Param("score") java.math.BigDecimal score,
@Param("teacherUserId") String teacherUserId,
@Param("scoredAt") Date scoredAt,
@Param("teacherComment") String teacherComment);
List<StudentTrainingAnswer> selectByStudentAndTeachingClass(@Param("studentUserId") String studentUserId,
@Param("teachingClassId") String teachingClassId);
}

@ -3,6 +3,8 @@ package com.sztzjy.linkCommerce.service;
import com.sztzjy.linkCommerce.config.security.JwtUser;
import com.sztzjy.linkCommerce.entity.dto.AiTrainingEvaluationView;
import java.util.Map;
public interface AiTrainingEvaluationService {
AiTrainingEvaluationView get(String taskKey, JwtUser user);
@ -13,4 +15,15 @@ public interface AiTrainingEvaluationService {
}
AiTrainingEvaluationView generateAssessment(String taskKey, JwtUser user);
AiTrainingEvaluationView startAssessment(String taskKey, JwtUser user);
AiTrainingEvaluationView getForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher);
AiTrainingEvaluationView startAssessmentForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher);
/**
* 教师评阅用:基于该生 AI 评分报告与 AI 助学报告生成 10-50 字中文评语。
* 返回 {comment: 评语};该生尚无 AI 评分数据时抛出 ServiceException。
*/
Map<String, String> generateCommentForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher);
}

@ -33,8 +33,10 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@ -144,8 +146,6 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
answerSnapshot.length());
return toView(evaluation);
}
String taskSnapshot = taskSnapshot(context.effectiveContent);
List<AssessmentCriterionPlan> criterionPlan = assessmentCriterionPlan(context.effectiveContent.getSteps());
int claimed = reassessment
? aiTrainingEvaluationMapper.claimReassessment(evaluation.getId(), evaluation.getAssessmentAnswerSnapshot(), new Date())
: aiTrainingEvaluationMapper.claimAssessment(evaluation.getId(), new Date());
@ -155,12 +155,74 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
return currentView(context, user);
}
return runAssessment(context, user, evaluation);
}
@Override
public AiTrainingEvaluationView startAssessment(String taskKey, JwtUser user) {
EvaluationContext context = resolveContext(taskKey, user, true);
AiTrainingEvaluation evaluation = getOrCreate(context, user);
if (PROCESSING.equals(evaluation.getAssessmentStatus()) && !isProcessingTimedOut(evaluation)) return toView(evaluation);
String snapshot = answerSnapshot(context.answer);
boolean reassessment = SUCCEEDED.equals(evaluation.getAssessmentStatus());
if (reassessment && snapshot.equals(evaluation.getAssessmentAnswerSnapshot())) return toView(evaluation);
int claimed = reassessment
? aiTrainingEvaluationMapper.claimReassessment(evaluation.getId(), evaluation.getAssessmentAnswerSnapshot(), new Date())
: aiTrainingEvaluationMapper.claimAssessment(evaluation.getId(), new Date());
if (claimed != 1) return currentView(context, user);
CompletableFuture.runAsync(() -> {
try { runAssessment(context, user, evaluation); }
catch (RuntimeException e) { log.warn("Student-triggered AI assessment failed, evaluationId={}", evaluation.getId(), e); }
});
AiTrainingEvaluationView view = toView(evaluation);
view.setAssessmentStatus(PROCESSING);
return view;
}
@Override
public AiTrainingEvaluationView getForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher) {
EvaluationContext context = resolveTeacherContext(classId, taskId, studentUserId, teacher, false);
return toView(getOrCreate(context, studentUserId));
}
@Override
public AiTrainingEvaluationView startAssessmentForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher) {
EvaluationContext context = resolveTeacherContext(classId, taskId, studentUserId, teacher, true);
AiTrainingEvaluation evaluation = getOrCreate(context, studentUserId);
if (PROCESSING.equals(evaluation.getAssessmentStatus()) && !isProcessingTimedOut(evaluation)) {
return toView(evaluation);
}
String answerSnapshot = answerSnapshot(context.answer);
boolean reassessment = SUCCEEDED.equals(evaluation.getAssessmentStatus());
if (reassessment && answerSnapshot.equals(evaluation.getAssessmentAnswerSnapshot())) {
return toView(evaluation);
}
int claimed = reassessment
? aiTrainingEvaluationMapper.claimReassessment(evaluation.getId(), evaluation.getAssessmentAnswerSnapshot(), new Date())
: aiTrainingEvaluationMapper.claimAssessment(evaluation.getId(), new Date());
if (claimed != 1) return currentView(context, studentUserId);
AiTrainingEvaluation processing = new AiTrainingEvaluation();
processing.setId(evaluation.getId());
processing.setHelpStatus(evaluation.getHelpStatus());
processing.setAssessmentStatus(PROCESSING);
CompletableFuture.runAsync(() -> {
try {
runAssessment(context, teacher, evaluation);
} catch (RuntimeException e) {
log.warn("Teacher-triggered AI assessment failed, evaluationId={}", evaluation.getId(), e);
}
});
return toView(processing);
}
private AiTrainingEvaluationView runAssessment(EvaluationContext context, JwtUser actor, AiTrainingEvaluation evaluation) {
String answerSnapshot = answerSnapshot(context.answer);
String taskSnapshot = taskSnapshot(context.effectiveContent);
List<AssessmentCriterionPlan> criterionPlan = assessmentCriterionPlan(context.effectiveContent.getSteps());
String rawResponse = null;
try {
String knowledgeContext = knowledgeContext(context, user, "");
String knowledgeContext = knowledgeContext(context, actor, "");
String choiceQuestionContext = choiceQuestionContext(context.effectiveContent, context.answer);
log.info("AI assessment choice question context prepared, evaluationId={}, entries={}",
evaluation.getId(), choiceQuestionEntryCount(choiceQuestionContext));
rawResponse = qwenChatClient.completeJson(assessmentSystemPrompt(criterionPlan),
assessmentUserPrompt(taskSnapshot, answerSnapshot, knowledgeContext, choiceQuestionContext));
JsonNode assessmentReport = object(rawResponse);
@ -178,9 +240,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
completed.setAssessmentCompletedAt(completedAt);
completed.setUpdateTime(completedAt);
completeAssessmentAndWriteScore(completed, context.answer.getId(), score, completedAt);
// 教师端成绩列表读的是 stu_rank 每日快照(凌晨1点定时重建),
// 不在这里立即刷新本班快照,新 AI 分数要等次日才会出现在教师端。
refreshScoreRankAfterAssessment(user, context.teachingClassId);
refreshScoreRankAfterAssessment(actor, context.teachingClassId);
completed.setHelpStatus(evaluation.getHelpStatus());
completed.setAssessmentStatus(SUCCEEDED);
return toView(completed);
@ -209,6 +269,74 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
EffectiveTrainingTaskContent.resolve(task, classConfig));
}
private EvaluationContext resolveTeacherContext(String classId, String taskId, String studentUserId,
JwtUser teacher, boolean requireAnswer) {
if (teacher == null || StringUtils.isBlank(teacher.getUserId())) {
throw new ServiceException(HttpStatus.UNAUTHORIZED, "Please login first");
}
if (StringUtils.isAnyBlank(classId, taskId, studentUserId)) {
throw new ServiceException(HttpStatus.BAD_REQUEST, "班级、任务和学生不能为空");
}
TrainingTask task = trainingTaskMapper.selectByPrimaryKey(taskId);
if (task == null) throw new ServiceException(HttpStatus.BAD_REQUEST, "Training task does not exist");
if (teachingClassStudentMapper.countByStudentUserIdAndTeachingClassId(studentUserId, classId) == 0) {
throw new ServiceException(HttpStatus.BAD_REQUEST, "该学生不在当前教学班");
}
StudentTrainingAnswer answer = studentTrainingAnswerMapper.selectByStudentClassAndTask(studentUserId, classId, taskId);
if (requireAnswer && (answer == null || !hasAnyAnswer(answer))) {
throw new ServiceException(HttpStatus.BAD_REQUEST, "该学生尚未完成可评分内容");
}
TrainingTaskClassConfig classConfig = trainingTaskClassConfigMapper == null ? null
: trainingTaskClassConfigMapper.selectByTeachingClassAndTaskKey(classId, task.getTaskKey());
return new EvaluationContext(task, classId, answer, EffectiveTrainingTaskContent.resolve(task, classConfig));
}
@Override
public Map<String, String> generateCommentForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher) {
EvaluationContext context = resolveTeacherContext(classId, taskId, studentUserId, teacher, false);
AiTrainingEvaluation evaluation = aiTrainingEvaluationMapper.findByStudentClassAndTask(
studentUserId, context.teachingClassId, context.task.getId());
String assessmentReport = evaluation == null ? "" : StringUtils.trimToEmpty(evaluation.getAssessmentReportJson());
String helpReport = evaluation == null ? "" : StringUtils.trimToEmpty(evaluation.getHelpReportJson());
if (StringUtils.isBlank(assessmentReport)) {
// AI 评分报告是评语的主要依据;助学报告只做补充,没有也能生成
throw new ServiceException(HttpStatus.BAD_REQUEST, "该学生还没有AI评分数据,请先执行AI评分");
}
String rawResponse = qwenChatClient.completeJson(commentSystemPrompt(),
commentUserPrompt(assessmentReport, helpReport));
JsonNode report = object(rawResponse);
JsonNode commentNode = report.get("comment");
if (commentNode == null || !commentNode.isTextual() || StringUtils.isBlank(commentNode.textValue())) {
throw invalidReport();
}
return Collections.singletonMap("comment", normalizeTeacherComment(commentNode.textValue()));
}
private String commentSystemPrompt() {
return "You are a vocational school teacher writing feedback for one student's training task. "
+ "Based on the given AI scoring report (and AI tutoring report if present), write ONE Chinese teacher comment. "
+ "Requirements: 10 to 50 Chinese characters; a single short paragraph; objective, specific and encouraging; "
+ "comment on the performance and give one improvement direction; "
+ "no bullet points, no quotes, no salutation, no prefix or suffix such as 评语. "
+ "Return only JSON: {\"comment\":\"...\"}";
}
private String commentUserPrompt(String assessmentReport, String helpReport) {
return "AI评分报告: " + assessmentReport + "\nAI助学报告: " + (StringUtils.isBlank(helpReport) ? "(无)" : helpReport);
}
/** 评语超 50 字时按句子边界截断;首尾引号一并去掉。 */
private String normalizeTeacherComment(String comment) {
String value = StringUtils.trimToEmpty(comment)
.replaceAll("^[\"'“”‘’]+", "")
.replaceAll("[\"'“”‘’]+$", "")
.trim();
if (value.codePointCount(0, value.length()) > 50) {
value = truncateRationaleAtSentenceBoundary(value, 50);
}
return value;
}
private void completeAssessmentAndWriteScore(AiTrainingEvaluation completed,
String answerId,
int score,
@ -246,15 +374,19 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
}
private AiTrainingEvaluation getOrCreate(EvaluationContext context, JwtUser user) {
return getOrCreate(context, user.getUserId());
}
private AiTrainingEvaluation getOrCreate(EvaluationContext context, String studentUserId) {
AiTrainingEvaluation evaluation = aiTrainingEvaluationMapper.findByStudentClassAndTask(
user.getUserId(), context.teachingClassId, context.task.getId());
studentUserId, context.teachingClassId, context.task.getId());
if (evaluation != null) {
return evaluation;
}
Date now = new Date();
AiTrainingEvaluation created = new AiTrainingEvaluation();
created.setId(UUID.randomUUID().toString());
created.setStudentUserId(user.getUserId());
created.setStudentUserId(studentUserId);
created.setTeachingClassId(context.teachingClassId);
created.setTaskId(context.task.getId());
created.setTaskKey(context.task.getTaskKey());
@ -264,7 +396,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
created.setUpdateTime(now);
aiTrainingEvaluationMapper.insertIgnore(created);
evaluation = aiTrainingEvaluationMapper.findByStudentClassAndTask(
user.getUserId(), context.teachingClassId, context.task.getId());
studentUserId, context.teachingClassId, context.task.getId());
if (evaluation == null) {
throw new ServiceException(HttpStatus.SERVICE_UNAVAILABLE, "AI evaluation is temporarily unavailable");
}
@ -276,6 +408,11 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
user.getUserId(), context.teachingClassId, context.task.getId()));
}
private AiTrainingEvaluationView currentView(EvaluationContext context, String studentUserId) {
return toView(aiTrainingEvaluationMapper.findByStudentClassAndTask(
studentUserId, context.teachingClassId, context.task.getId()));
}
private void requireStudent(JwtUser user) {
if (user == null || StringUtils.isBlank(user.getUserId())) {
throw new ServiceException(HttpStatus.UNAUTHORIZED, "Please login first");
@ -609,7 +746,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
private String assessmentSystemPrompt(List<AssessmentCriterionPlan> criterionPlan) {
return "You are a training assessor. Return exactly one JSON object, with no Markdown fence or other text. "
+ "Required fields: score (integer total earned from criteria), overallComment (Chinese summary), "
+ "Required fields: score (integer total earned from criteria), overallComment (Chinese summary of 30 to 50 Chinese characters), "
+ "criteria (array of objects with name, maxScore, score, rationale), strengths (array of Chinese strings), "
+ "and improvements (array of Chinese strings). "
+ "Assess each step independently using the task requirements, this student's saved answers and available case/file evidence. "
@ -660,12 +797,36 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
view.setHelpReportJson(evaluation.getHelpReportJson());
view.setHelpCompletedAt(evaluation.getHelpCompletedAt());
view.setAssessmentScore(evaluation.getAssessmentScore());
view.setAssessmentComment(assessmentComment(evaluation.getAssessmentReportJson()));
view.setAssessmentReportJson(evaluation.getAssessmentReportJson());
view.setAssessmentCompletedAt(evaluation.getAssessmentCompletedAt());
}
return view;
}
private String assessmentComment(String reportJson) {
if (StringUtils.isBlank(reportJson)) return "";
try {
JsonNode report = objectMapper.readTree(reportJson);
String comment = StringUtils.trimToEmpty(report.path("overallComment").asText());
if (StringUtils.isBlank(comment)) comment = StringUtils.trimToEmpty(report.path("overallDiagnosis").asText());
if (StringUtils.isBlank(comment)) comment = StringUtils.trimToEmpty(report.path("strengths").path(0).asText());
if (StringUtils.isBlank(comment)) return "";
if (comment.codePointCount(0, comment.length()) < 30 && report.path("criteria").isArray()) {
for (JsonNode criterion : report.path("criteria")) {
String rationale = StringUtils.trimToEmpty(criterion.path("rationale").asText());
if (StringUtils.isNotBlank(rationale)) comment = comment + rationale;
if (comment.codePointCount(0, comment.length()) >= 30) break;
}
}
int count = comment.codePointCount(0, comment.length());
if (count > 50) comment = comment.substring(0, comment.offsetByCodePoints(0, 50));
return comment;
} catch (Exception ignored) {
return "";
}
}
/**
* 落库的失败原因:只保存我们自己抛出的 ServiceException 文案,
* 其余异常(数据库、网络等)的原文只写日志,避免把 SQL 等长文本存进库或带给前端。

@ -18,6 +18,7 @@
<result column="submitted" jdbcType="BIT" property="submitted" />
<result column="ai_assessment_score" jdbcType="INTEGER" property="aiAssessmentScore" />
<result column="teacher_score" jdbcType="DECIMAL" property="teacherScore" />
<result column="teacher_comment" jdbcType="VARCHAR" property="teacherComment" />
<result column="teacher_scored_by" jdbcType="VARCHAR" property="teacherScoredBy" />
<result column="teacher_scored_at" jdbcType="TIMESTAMP" property="teacherScoredAt" />
<result column="submit_time" jdbcType="TIMESTAMP" property="submitTime" />
@ -27,7 +28,7 @@
<sql id="Base_Column_List">
id, student_user_id, teaching_class_id, task_id, task_key, task_name, step1_answer, step2_answer,
step3_answer, step4_answer, dynamic_step_answers, current_step, progress_status, submitted, ai_assessment_score, teacher_score, teacher_scored_by, teacher_scored_at, submit_time, create_time, update_time
step3_answer, step4_answer, dynamic_step_answers, current_step, progress_status, submitted, ai_assessment_score, teacher_score, teacher_comment, teacher_scored_by, teacher_scored_at, submit_time, create_time, update_time
</sql>
<select id="selectByPrimaryKey" parameterType="java.lang.String" resultMap="BaseResultMap">
@ -79,6 +80,7 @@
<if test="submitted != null">submitted,</if>
<if test="aiAssessmentScore != null">ai_assessment_score,</if>
<if test="teacherScore != null">teacher_score,</if>
<if test="teacherComment != null">teacher_comment,</if>
<if test="teacherScoredBy != null">teacher_scored_by,</if>
<if test="teacherScoredAt != null">teacher_scored_at,</if>
<if test="submitTime != null">submit_time,</if>
@ -102,6 +104,7 @@
<if test="submitted != null">#{submitted,jdbcType=BIT},</if>
<if test="aiAssessmentScore != null">#{aiAssessmentScore,jdbcType=INTEGER},</if>
<if test="teacherScore != null">#{teacherScore,jdbcType=DECIMAL},</if>
<if test="teacherComment != null">#{teacherComment,jdbcType=VARCHAR},</if>
<if test="teacherScoredBy != null">#{teacherScoredBy,jdbcType=VARCHAR},</if>
<if test="teacherScoredAt != null">#{teacherScoredAt,jdbcType=TIMESTAMP},</if>
<if test="submitTime != null">#{submitTime,jdbcType=TIMESTAMP},</if>
@ -128,6 +131,7 @@
<if test="submitted != null">submitted = #{submitted,jdbcType=BIT},</if>
<if test="aiAssessmentScore != null">ai_assessment_score = #{aiAssessmentScore,jdbcType=INTEGER},</if>
<if test="teacherScore != null">teacher_score = #{teacherScore,jdbcType=DECIMAL},</if>
<if test="teacherComment != null">teacher_comment = #{teacherComment,jdbcType=VARCHAR},</if>
<if test="teacherScoredBy != null">teacher_scored_by = #{teacherScoredBy,jdbcType=VARCHAR},</if>
<if test="teacherScoredAt != null">teacher_scored_at = #{teacherScoredAt,jdbcType=TIMESTAMP},</if>
<if test="submitTime != null">submit_time = #{submitTime,jdbcType=TIMESTAMP},</if>
@ -152,4 +156,13 @@
update_time = #{scoredAt,jdbcType=TIMESTAMP}
where id = #{id,jdbcType=VARCHAR}
</update>
<update id="updateTeacherScoreWithComment">
update student_training_answer
set teacher_score = #{score,jdbcType=DECIMAL},
teacher_comment = #{teacherComment,jdbcType=VARCHAR},
teacher_scored_by = #{teacherUserId,jdbcType=VARCHAR},
teacher_scored_at = #{scoredAt,jdbcType=TIMESTAMP},
update_time = #{scoredAt,jdbcType=TIMESTAMP}
where id = #{id,jdbcType=VARCHAR}
</update>
</mapper>

Loading…
Cancel
Save