diff --git a/docs/sql/2026-10-16-task-review-ai-comment.sql b/docs/sql/2026-10-16-task-review-ai-comment.sql new file mode 100644 index 0000000..950bebc --- /dev/null +++ b/docs/sql/2026-10-16-task-review-ai-comment.sql @@ -0,0 +1,3 @@ +-- 教师任务评阅评语字段;应用启动时也会由 schema initializer 自动补列。 +ALTER TABLE student_training_answer + ADD COLUMN teacher_comment varchar(1000) NULL AFTER teacher_score; diff --git a/pom.xml b/pom.xml index 2f3e74c..9447651 100644 --- a/pom.xml +++ b/pom.xml @@ -140,6 +140,9 @@ org.projectlombok lombok + + 1.18.32 org.hibernate diff --git a/src/main/java/com/sztzjy/linkCommerce/config/StudentTrainingAnswerTeacherScoreSchemaInitializer.java b/src/main/java/com/sztzjy/linkCommerce/config/StudentTrainingAnswerTeacherScoreSchemaInitializer.java index fe2ffd1..57a3558 100644 --- a/src/main/java/com/sztzjy/linkCommerce/config/StudentTrainingAnswerTeacherScoreSchemaInitializer.java +++ b/src/main/java/com/sztzjy/linkCommerce/config/StudentTrainingAnswerTeacherScoreSchemaInitializer.java @@ -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) { diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationController.java b/src/main/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationController.java index 2b88298..aec941e 100644 --- a/src/main/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationController.java +++ b/src/main/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationController.java @@ -56,8 +56,10 @@ public class AiTrainingEvaluationController { @ApiOperation("Generate AI assessment") public ResultEntity 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()); } diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/stu/TeaScoreController.java b/src/main/java/com/sztzjy/linkCommerce/controller/stu/TeaScoreController.java index 5d3001a..4a3387a 100644 --- a/src/main/java/com/sztzjy/linkCommerce/controller/stu/TeaScoreController.java +++ b/src/main/java/com/sztzjy/linkCommerce/controller/stu/TeaScoreController.java @@ -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 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 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> 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> 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 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 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()); diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/stu/TrainingTaskController.java b/src/main/java/com/sztzjy/linkCommerce/controller/stu/TrainingTaskController.java index 765b015..521b955 100644 --- a/src/main/java/com/sztzjy/linkCommerce/controller/stu/TrainingTaskController.java +++ b/src/main/java/com/sztzjy/linkCommerce/controller/stu/TrainingTaskController.java @@ -41,8 +41,10 @@ public class TrainingTaskController { HttpServletRequest request) { JwtUser user = TokenProvider.getJWTUser(request); if (isStudent(user)) { + List 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) { diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/StudentTrainingAnswer.java b/src/main/java/com/sztzjy/linkCommerce/entity/StudentTrainingAnswer.java index e407f77..74837e7 100644 --- a/src/main/java/com/sztzjy/linkCommerce/entity/StudentTrainingAnswer.java +++ b/src/main/java/com/sztzjy/linkCommerce/entity/StudentTrainingAnswer.java @@ -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; diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/dto/AiTrainingEvaluationView.java b/src/main/java/com/sztzjy/linkCommerce/entity/dto/AiTrainingEvaluationView.java index 3549116..6f81f5d 100644 --- a/src/main/java/com/sztzjy/linkCommerce/entity/dto/AiTrainingEvaluationView.java +++ b/src/main/java/com/sztzjy/linkCommerce/entity/dto/AiTrainingEvaluationView.java @@ -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; } diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/dto/BatchTaskAiAssessmentRequest.java b/src/main/java/com/sztzjy/linkCommerce/entity/dto/BatchTaskAiAssessmentRequest.java new file mode 100644 index 0000000..2ed1512 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/entity/dto/BatchTaskAiAssessmentRequest.java @@ -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 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 getStudentUserIds() { return studentUserIds; } + public void setStudentUserIds(List studentUserIds) { this.studentUserIds = studentUserIds; } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/dto/BatchTaskTeacherScoreRequest.java b/src/main/java/com/sztzjy/linkCommerce/entity/dto/BatchTaskTeacherScoreRequest.java index 6fdf69e..c3c06c6 100644 --- a/src/main/java/com/sztzjy/linkCommerce/entity/dto/BatchTaskTeacherScoreRequest.java +++ b/src/main/java/com/sztzjy/linkCommerce/entity/dto/BatchTaskTeacherScoreRequest.java @@ -8,6 +8,7 @@ public class BatchTaskTeacherScoreRequest { private String taskId; private List 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 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; } } diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/dto/TeacherTaskReviewOptionDTO.java b/src/main/java/com/sztzjy/linkCommerce/entity/dto/TeacherTaskReviewOptionDTO.java index 536f30e..aca8a22 100644 --- a/src/main/java/com/sztzjy/linkCommerce/entity/dto/TeacherTaskReviewOptionDTO.java +++ b/src/main/java/com/sztzjy/linkCommerce/entity/dto/TeacherTaskReviewOptionDTO.java @@ -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; } } diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/dto/TeacherTaskReviewStudentDTO.java b/src/main/java/com/sztzjy/linkCommerce/entity/dto/TeacherTaskReviewStudentDTO.java index 4f5945f..451a79b 100644 --- a/src/main/java/com/sztzjy/linkCommerce/entity/dto/TeacherTaskReviewStudentDTO.java +++ b/src/main/java/com/sztzjy/linkCommerce/entity/dto/TeacherTaskReviewStudentDTO.java @@ -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; } diff --git a/src/main/java/com/sztzjy/linkCommerce/mapper/StudentTrainingAnswerMapper.java b/src/main/java/com/sztzjy/linkCommerce/mapper/StudentTrainingAnswerMapper.java index 138e62b..38c0d53 100644 --- a/src/main/java/com/sztzjy/linkCommerce/mapper/StudentTrainingAnswerMapper.java +++ b/src/main/java/com/sztzjy/linkCommerce/mapper/StudentTrainingAnswerMapper.java @@ -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 selectByStudentAndTeachingClass(@Param("studentUserId") String studentUserId, @Param("teachingClassId") String teachingClassId); } diff --git a/src/main/java/com/sztzjy/linkCommerce/service/AiTrainingEvaluationService.java b/src/main/java/com/sztzjy/linkCommerce/service/AiTrainingEvaluationService.java index c4fe546..c4395a7 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/AiTrainingEvaluationService.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/AiTrainingEvaluationService.java @@ -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 generateCommentForTeacher(String classId, String taskId, String studentUserId, JwtUser teacher); } diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java index 1d5b440..c5845d2 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java @@ -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 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 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 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 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 等长文本存进库或带给前端。 diff --git a/src/main/resources/mappers/StudentTrainingAnswerMapper.xml b/src/main/resources/mappers/StudentTrainingAnswerMapper.xml index 87c3d75..4761deb 100644 --- a/src/main/resources/mappers/StudentTrainingAnswerMapper.xml +++ b/src/main/resources/mappers/StudentTrainingAnswerMapper.xml @@ -18,6 +18,7 @@ + @@ -27,7 +28,7 @@ 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