From 2d6efbe9ea7d670ca8b42c49a0dc2957d715da3e Mon Sep 17 00:00:00 2001 From: chenyuan Date: Thu, 16 Jul 2026 11:56:14 +0800 Subject: [PATCH] feat: add AI help and assessment APIs --- .superpowers/sdd/task-3-report.md | 38 ++ .../stu/AiTrainingEvaluationController.java | 64 ++++ .../entity/dto/AiTrainingEvaluationView.java | 29 ++ .../service/AiTrainingEvaluationService.java | 12 + .../impl/AiTrainingEvaluationServiceImpl.java | 355 ++++++++++++++++++ .../AiTrainingEvaluationControllerTest.java | 66 ++++ .../AiTrainingEvaluationServiceImplTest.java | 191 ++++++++++ 7 files changed, 755 insertions(+) create mode 100644 .superpowers/sdd/task-3-report.md create mode 100644 src/main/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationController.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/entity/dto/AiTrainingEvaluationView.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/service/AiTrainingEvaluationService.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java create mode 100644 src/test/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationControllerTest.java diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md new file mode 100644 index 0000000..8c19505 --- /dev/null +++ b/.superpowers/sdd/task-3-report.md @@ -0,0 +1,38 @@ +# Task 3: AI Training Evaluation Service/API Report + +## Delivered + +- Added the student-safe `AiTrainingEvaluationView`, excluding task/answer snapshots, raw model output, and internal failure details. +- Added `AiTrainingEvaluationService` and its implementation for evaluation retrieval plus AI help and assessment generation. + - Requires an authenticated student JWT, resolves the task, active teaching class, and saved answer server-side, and rejects answers whose four steps are blank. + - Uses the existing atomic aggregate-row mapper transitions: cached success returns the stored report, processing returns state without calling Qwen, and failed evaluations can be claimed again. + - Persists immutable task (`background`, `objectives`, `requirements`) and four-step answer snapshots only with a successful report. + - Calls Qwen after the database claim and outside a transaction; validates help schema and assessment score/criteria schema before persistence. + - Caps persisted failure messages at 1000 characters and updates `student_training_answer.ai_assessment_score` only after a valid assessment report is persisted. +- Added the student endpoints: + - `GET /api/student/training-tasks/{taskKey}/ai-evaluation` + - `POST /api/student/training-tasks/{taskKey}/ai-evaluation/help` + - `POST /api/student/training-tasks/{taskKey}/ai-evaluation/assessment` +- Added Mockito tests for blank answers, immutable snapshots, score write-back, invalid criteria maxima, cached success, retry after failure, processing state, non-student access, and controller delegation/error mapping. + +## Test-first evidence + +1. Added the Task 3 service/controller tests before the production DTO, service, and controller existed. +2. Ran the selected Maven tests and observed the expected RED compilation failure because these three production classes were absent. +3. Implemented the minimum production API/service, then ran the targeted tests until green. + +## Verification + +```text +mvn -Dtest=AiTrainingEvaluationServiceImplTest,DashScopeQwenChatClientTest,AiTrainingEvaluationControllerTest,StudentTrainingAnswerServiceImplTest test +Tests run: 23, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS + +mvn -DskipTests compile +BUILD SUCCESS +``` + +## Concerns + +- The supplied valid 86-point fixture has criterion earned scores of 34/30/22 (total 86). Validation therefore requires criterion maxima to total 100 and criterion earned scores to equal the top-level score; requiring earned criteria to total 100 would reject that specified fixture. +- Maven continues to emit pre-existing warnings for project-local Aspose system dependencies and the relocated MySQL artifact. diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationController.java b/src/main/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationController.java new file mode 100644 index 0000000..e808330 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationController.java @@ -0,0 +1,64 @@ +package com.sztzjy.linkCommerce.controller.stu; + +import com.sztzjy.linkCommerce.config.exception.handler.ServiceException; +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.config.security.TokenProvider; +import com.sztzjy.linkCommerce.entity.dto.AiTrainingEvaluationView; +import com.sztzjy.linkCommerce.service.AiTrainingEvaluationService; +import com.sztzjy.linkCommerce.util.ResultEntity; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; + +@Api(tags = "Student AI training evaluation") +@RestController +@RequestMapping("api/student/training-tasks/{taskKey}/ai-evaluation") +public class AiTrainingEvaluationController { + @Autowired + protected AiTrainingEvaluationService aiTrainingEvaluationService; + + @GetMapping + @ApiOperation("Get the current student's AI training evaluation") + public ResultEntity get(@PathVariable String taskKey, HttpServletRequest request) { + try { + return new ResultEntity<>(HttpStatus.OK, "Query succeeded", + aiTrainingEvaluationService.get(taskKey, currentUser(request))); + } catch (ServiceException e) { + return new ResultEntity<>(e.getCode(), e.getMessage()); + } + } + + @PostMapping("/help") + @ApiOperation("Generate AI learning help") + public ResultEntity help(@PathVariable String taskKey, HttpServletRequest request) { + try { + return new ResultEntity<>(HttpStatus.OK, "AI help generated", + aiTrainingEvaluationService.generateHelp(taskKey, currentUser(request))); + } catch (ServiceException e) { + return new ResultEntity<>(e.getCode(), e.getMessage()); + } + } + + @PostMapping("/assessment") + @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))); + } catch (ServiceException e) { + return new ResultEntity<>(e.getCode(), e.getMessage()); + } + } + + protected JwtUser currentUser(HttpServletRequest request) { + return TokenProvider.getJWTUser(request); + } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/dto/AiTrainingEvaluationView.java b/src/main/java/com/sztzjy/linkCommerce/entity/dto/AiTrainingEvaluationView.java new file mode 100644 index 0000000..3549116 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/entity/dto/AiTrainingEvaluationView.java @@ -0,0 +1,29 @@ +package com.sztzjy.linkCommerce.entity.dto; + +import java.util.Date; + +/** Safe student-facing projection; internal snapshots and raw provider output stay private. */ +public class AiTrainingEvaluationView { + private String helpStatus; + private String helpReportJson; + private Date helpCompletedAt; + private String assessmentStatus; + private Integer assessmentScore; + private String assessmentReportJson; + private Date assessmentCompletedAt; + + public String getHelpStatus() { return helpStatus; } + public void setHelpStatus(String helpStatus) { this.helpStatus = helpStatus; } + public String getHelpReportJson() { return helpReportJson; } + public void setHelpReportJson(String helpReportJson) { this.helpReportJson = helpReportJson; } + public Date getHelpCompletedAt() { return helpCompletedAt; } + public void setHelpCompletedAt(Date helpCompletedAt) { this.helpCompletedAt = helpCompletedAt; } + public String getAssessmentStatus() { return assessmentStatus; } + public void setAssessmentStatus(String assessmentStatus) { this.assessmentStatus = assessmentStatus; } + public Integer getAssessmentScore() { return assessmentScore; } + public void setAssessmentScore(Integer assessmentScore) { this.assessmentScore = assessmentScore; } + public String getAssessmentReportJson() { return assessmentReportJson; } + public void setAssessmentReportJson(String assessmentReportJson) { this.assessmentReportJson = assessmentReportJson; } + public Date getAssessmentCompletedAt() { return assessmentCompletedAt; } + public void setAssessmentCompletedAt(Date assessmentCompletedAt) { this.assessmentCompletedAt = assessmentCompletedAt; } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/service/AiTrainingEvaluationService.java b/src/main/java/com/sztzjy/linkCommerce/service/AiTrainingEvaluationService.java new file mode 100644 index 0000000..67394e7 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/service/AiTrainingEvaluationService.java @@ -0,0 +1,12 @@ +package com.sztzjy.linkCommerce.service; + +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.entity.dto.AiTrainingEvaluationView; + +public interface AiTrainingEvaluationService { + AiTrainingEvaluationView get(String taskKey, JwtUser user); + + AiTrainingEvaluationView generateHelp(String taskKey, JwtUser user); + + AiTrainingEvaluationView generateAssessment(String taskKey, JwtUser user); +} diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java new file mode 100644 index 0000000..dddc697 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java @@ -0,0 +1,355 @@ +package com.sztzjy.linkCommerce.service.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.sztzjy.linkCommerce.ai.QwenChatClient; +import com.sztzjy.linkCommerce.config.exception.handler.ServiceException; +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.entity.AiTrainingEvaluation; +import com.sztzjy.linkCommerce.entity.StudentTrainingAnswer; +import com.sztzjy.linkCommerce.entity.TeachingClassStudent; +import com.sztzjy.linkCommerce.entity.TrainingTask; +import com.sztzjy.linkCommerce.entity.dto.AiTrainingEvaluationView; +import com.sztzjy.linkCommerce.mapper.AiTrainingEvaluationMapper; +import com.sztzjy.linkCommerce.mapper.StudentTrainingAnswerMapper; +import com.sztzjy.linkCommerce.mapper.TeachingClassStudentMapper; +import com.sztzjy.linkCommerce.mapper.TrainingTaskMapper; +import com.sztzjy.linkCommerce.service.AiTrainingEvaluationService; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.UUID; + +@Service +public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationService { + private static final String NOT_STARTED = "NOT_STARTED"; + private static final String PROCESSING = "PROCESSING"; + private static final String SUCCEEDED = "SUCCEEDED"; + private static final int MAX_ERROR_LENGTH = 1000; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + public AiTrainingEvaluationMapper aiTrainingEvaluationMapper; + @Autowired + public TrainingTaskMapper trainingTaskMapper; + @Autowired + public StudentTrainingAnswerMapper studentTrainingAnswerMapper; + @Autowired + public TeachingClassStudentMapper teachingClassStudentMapper; + @Autowired + public QwenChatClient qwenChatClient; + + @Override + public AiTrainingEvaluationView get(String taskKey, JwtUser user) { + EvaluationContext context = resolveContext(taskKey, user, false); + AiTrainingEvaluation evaluation = aiTrainingEvaluationMapper.findByStudentClassAndTask( + user.getUserId(), context.teachingClassId, context.task.getId()); + return toView(evaluation); + } + + @Override + public AiTrainingEvaluationView generateHelp(String taskKey, JwtUser user) { + EvaluationContext context = resolveContext(taskKey, user, true); + AiTrainingEvaluation evaluation = getOrCreate(context, user); + if (isTerminalOrProcessing(evaluation.getHelpStatus())) { + return toView(evaluation); + } + if (aiTrainingEvaluationMapper.claimHelp(evaluation.getId(), new Date()) != 1) { + return currentView(context, user); + } + + String taskSnapshot = taskSnapshot(context.task); + String answerSnapshot = answerSnapshot(context.answer); + try { + String rawResponse = qwenChatClient.completeJson(helpSystemPrompt(), helpUserPrompt(taskSnapshot, answerSnapshot)); + validateHelp(rawResponse); + Date completedAt = new Date(); + AiTrainingEvaluation completed = new AiTrainingEvaluation(); + completed.setId(evaluation.getId()); + completed.setHelpTaskSnapshot(taskSnapshot); + completed.setHelpAnswerSnapshot(answerSnapshot); + completed.setHelpReportJson(rawResponse); + completed.setHelpRawResponse(rawResponse); + completed.setHelpModel("qwen"); + completed.setHelpCompletedAt(completedAt); + completed.setUpdateTime(completedAt); + aiTrainingEvaluationMapper.completeHelp(completed); + completed.setHelpStatus(SUCCEEDED); + completed.setAssessmentStatus(evaluation.getAssessmentStatus()); + return toView(completed); + } catch (RuntimeException e) { + aiTrainingEvaluationMapper.failHelp(evaluation.getId(), cappedMessage(e), new Date()); + throw asServiceException(e); + } + } + + @Override + public AiTrainingEvaluationView generateAssessment(String taskKey, JwtUser user) { + EvaluationContext context = resolveContext(taskKey, user, true); + AiTrainingEvaluation evaluation = getOrCreate(context, user); + if (isTerminalOrProcessing(evaluation.getAssessmentStatus())) { + return toView(evaluation); + } + if (aiTrainingEvaluationMapper.claimAssessment(evaluation.getId(), new Date()) != 1) { + return currentView(context, user); + } + + String taskSnapshot = taskSnapshot(context.task); + String answerSnapshot = answerSnapshot(context.answer); + try { + String rawResponse = qwenChatClient.completeJson(assessmentSystemPrompt(), assessmentUserPrompt(taskSnapshot, answerSnapshot)); + int score = validateAssessment(rawResponse); + Date completedAt = new Date(); + AiTrainingEvaluation completed = new AiTrainingEvaluation(); + completed.setId(evaluation.getId()); + completed.setAssessmentTaskSnapshot(taskSnapshot); + completed.setAssessmentAnswerSnapshot(answerSnapshot); + completed.setAssessmentScore(score); + completed.setAssessmentReportJson(rawResponse); + completed.setAssessmentRawResponse(rawResponse); + completed.setAssessmentModel("qwen"); + completed.setAssessmentCompletedAt(completedAt); + completed.setUpdateTime(completedAt); + aiTrainingEvaluationMapper.completeAssessment(completed); + studentTrainingAnswerMapper.updateAiAssessmentScore(context.answer.getId(), score, completedAt); + completed.setHelpStatus(evaluation.getHelpStatus()); + completed.setAssessmentStatus(SUCCEEDED); + return toView(completed); + } catch (RuntimeException e) { + aiTrainingEvaluationMapper.failAssessment(evaluation.getId(), cappedMessage(e), new Date()); + throw asServiceException(e); + } + } + + private EvaluationContext resolveContext(String taskKey, JwtUser user, boolean requireAnswer) { + requireStudent(user); + TrainingTask task = resolveTask(taskKey); + TeachingClassStudent member = teachingClassStudentMapper.selectActiveByStudentUserId(user.getUserId()); + if (member == null || StringUtils.isBlank(member.getTeachingClassId())) { + throw new ServiceException(HttpStatus.BAD_REQUEST, "Student has no active teaching class"); + } + StudentTrainingAnswer answer = studentTrainingAnswerMapper.selectByStudentClassAndTask( + user.getUserId(), member.getTeachingClassId(), task.getId()); + if (requireAnswer && (answer == null || !hasAnyAnswer(answer))) { + throw new ServiceException(HttpStatus.BAD_REQUEST, "Please complete at least one training step before requesting AI evaluation"); + } + return new EvaluationContext(task, member.getTeachingClassId(), answer); + } + + private AiTrainingEvaluation getOrCreate(EvaluationContext context, JwtUser user) { + AiTrainingEvaluation evaluation = aiTrainingEvaluationMapper.findByStudentClassAndTask( + user.getUserId(), 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.setTeachingClassId(context.teachingClassId); + created.setTaskId(context.task.getId()); + created.setTaskKey(context.task.getTaskKey()); + created.setHelpStatus(NOT_STARTED); + created.setAssessmentStatus(NOT_STARTED); + created.setCreateTime(now); + created.setUpdateTime(now); + aiTrainingEvaluationMapper.insertIgnore(created); + evaluation = aiTrainingEvaluationMapper.findByStudentClassAndTask( + user.getUserId(), context.teachingClassId, context.task.getId()); + if (evaluation == null) { + throw new ServiceException(HttpStatus.SERVICE_UNAVAILABLE, "AI evaluation is temporarily unavailable"); + } + return evaluation; + } + + private AiTrainingEvaluationView currentView(EvaluationContext context, JwtUser user) { + return toView(aiTrainingEvaluationMapper.findByStudentClassAndTask( + user.getUserId(), 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"); + } + if (user.getRoleId() != 4) { + throw new ServiceException(HttpStatus.FORBIDDEN, "Only students can request AI evaluations"); + } + } + + private TrainingTask resolveTask(String taskKey) { + String normalizedTaskKey = StringUtils.trimToEmpty(taskKey); + if (StringUtils.isBlank(normalizedTaskKey)) { + throw new ServiceException(HttpStatus.BAD_REQUEST, "Training task key is required"); + } + TrainingTask task = trainingTaskMapper.selectByTaskKey(normalizedTaskKey); + if (task == null) { + throw new ServiceException(HttpStatus.BAD_REQUEST, "Training task does not exist"); + } + return task; + } + + private boolean hasAnyAnswer(StudentTrainingAnswer answer) { + return StringUtils.isNotBlank(answer.getStep1Answer()) || StringUtils.isNotBlank(answer.getStep2Answer()) + || StringUtils.isNotBlank(answer.getStep3Answer()) || StringUtils.isNotBlank(answer.getStep4Answer()); + } + + private boolean isTerminalOrProcessing(String status) { + return SUCCEEDED.equals(status) || PROCESSING.equals(status); + } + + private String taskSnapshot(TrainingTask task) { + ObjectNode node = objectMapper.createObjectNode(); + node.put("background", task.getBackground()); + node.put("objectives", task.getObjectives()); + node.put("requirements", task.getRequirements()); + return json(node); + } + + private String answerSnapshot(StudentTrainingAnswer answer) { + ObjectNode node = objectMapper.createObjectNode(); + node.put("step1Answer", answer.getStep1Answer()); + node.put("step2Answer", answer.getStep2Answer()); + node.put("step3Answer", answer.getStep3Answer()); + node.put("step4Answer", answer.getStep4Answer()); + return json(node); + } + + private String json(JsonNode node) { + try { + return objectMapper.writeValueAsString(node); + } catch (JsonProcessingException e) { + throw new ServiceException(HttpStatus.INTERNAL_SERVER_ERROR, "Could not prepare AI evaluation"); + } + } + + private void validateHelp(String report) { + JsonNode root = object(report); + requireText(root, "overallDiagnosis"); + requireArray(root, "strengths"); + requireArray(root, "improvementAreas"); + requireArray(root, "recommendedActions"); + } + + private int validateAssessment(String report) { + JsonNode root = object(report); + JsonNode score = root.get("score"); + if (score == null || !score.isInt() || score.intValue() < 0 || score.intValue() > 100) { + throw invalidReport(); + } + JsonNode criteria = root.get("criteria"); + if (criteria == null || !criteria.isArray() || criteria.size() < 3 || criteria.size() > 5) { + throw invalidReport(); + } + int maxTotal = 0; + int earnedTotal = 0; + for (JsonNode criterion : criteria) { + if (!criterion.isObject()) { + throw invalidReport(); + } + requireText(criterion, "name"); + requireText(criterion, "rationale"); + JsonNode max = criterion.get("maxScore"); + JsonNode earned = criterion.get("score"); + if (max == null || earned == null || !max.isInt() || !earned.isInt() + || max.intValue() < 0 || earned.intValue() < 0 || earned.intValue() > max.intValue()) { + throw invalidReport(); + } + maxTotal += max.intValue(); + earnedTotal += earned.intValue(); + } + if (maxTotal != 100 || earnedTotal != score.intValue()) { + throw invalidReport(); + } + return score.intValue(); + } + + private JsonNode object(String report) { + try { + JsonNode root = objectMapper.readTree(report); + if (root == null || !root.isObject()) { + throw invalidReport(); + } + return root; + } catch (JsonProcessingException e) { + throw invalidReport(); + } + } + + private void requireText(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || !value.isTextual() || StringUtils.isBlank(value.textValue())) { + throw invalidReport(); + } + } + + private void requireArray(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || !value.isArray()) { + throw invalidReport(); + } + } + + private ServiceException invalidReport() { + return new ServiceException(HttpStatus.BAD_GATEWAY, "AI returned an invalid evaluation report"); + } + + private String helpSystemPrompt() { + return "You are a training learning assistant. Return only JSON with overallDiagnosis, strengths, improvementAreas, and recommendedActions. Do not include a score."; + } + + private String helpUserPrompt(String taskSnapshot, String answerSnapshot) { + return "Task snapshot: " + taskSnapshot + "\nStudent answer snapshot: " + answerSnapshot; + } + + private String assessmentSystemPrompt() { + return "You are a training assessor. Return only JSON with score, overallComment, criteria, strengths, and improvements. Create 3 to 5 criteria; all maxScore values total 100, all criterion score values total 100, and the integer score equals that total."; + } + + private String assessmentUserPrompt(String taskSnapshot, String answerSnapshot) { + return "Task snapshot: " + taskSnapshot + "\nStudent answer snapshot: " + answerSnapshot; + } + + private AiTrainingEvaluationView toView(AiTrainingEvaluation evaluation) { + AiTrainingEvaluationView view = new AiTrainingEvaluationView(); + view.setHelpStatus(evaluation == null || StringUtils.isBlank(evaluation.getHelpStatus()) ? NOT_STARTED : evaluation.getHelpStatus()); + view.setAssessmentStatus(evaluation == null || StringUtils.isBlank(evaluation.getAssessmentStatus()) ? NOT_STARTED : evaluation.getAssessmentStatus()); + if (evaluation != null) { + view.setHelpReportJson(evaluation.getHelpReportJson()); + view.setHelpCompletedAt(evaluation.getHelpCompletedAt()); + view.setAssessmentScore(evaluation.getAssessmentScore()); + view.setAssessmentReportJson(evaluation.getAssessmentReportJson()); + view.setAssessmentCompletedAt(evaluation.getAssessmentCompletedAt()); + } + return view; + } + + private String cappedMessage(RuntimeException exception) { + String message = StringUtils.defaultIfBlank(exception.getMessage(), "AI evaluation failed"); + return StringUtils.left(message, MAX_ERROR_LENGTH); + } + + private ServiceException asServiceException(RuntimeException exception) { + if (exception instanceof ServiceException) { + return (ServiceException) exception; + } + return new ServiceException(HttpStatus.BAD_GATEWAY, "AI evaluation failed"); + } + + private static class EvaluationContext { + private final TrainingTask task; + private final String teachingClassId; + private final StudentTrainingAnswer answer; + + private EvaluationContext(TrainingTask task, String teachingClassId, StudentTrainingAnswer answer) { + this.task = task; + this.teachingClassId = teachingClassId; + this.answer = answer; + } + } +} diff --git a/src/test/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationControllerTest.java b/src/test/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationControllerTest.java new file mode 100644 index 0000000..50228e8 --- /dev/null +++ b/src/test/java/com/sztzjy/linkCommerce/controller/stu/AiTrainingEvaluationControllerTest.java @@ -0,0 +1,66 @@ +package com.sztzjy.linkCommerce.controller.stu; + +import com.sztzjy.linkCommerce.config.exception.handler.ServiceException; +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.entity.dto.AiTrainingEvaluationView; +import com.sztzjy.linkCommerce.service.AiTrainingEvaluationService; +import com.sztzjy.linkCommerce.util.ResultEntity; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +import javax.servlet.http.HttpServletRequest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AiTrainingEvaluationControllerTest { + + @Test + void helpDelegatesUsingJwtUserAndReturnsView() { + TestController controller = new TestController(student()); + controller.aiTrainingEvaluationService = mock(AiTrainingEvaluationService.class); + AiTrainingEvaluationView view = new AiTrainingEvaluationView(); + view.setHelpStatus("SUCCEEDED"); + when(controller.aiTrainingEvaluationService.generateHelp("task-key", controller.user)).thenReturn(view); + + ResultEntity result = controller.help("task-key", mock(HttpServletRequest.class)); + + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("SUCCEEDED", result.getBody().getData().getHelpStatus()); + verify(controller.aiTrainingEvaluationService).generateHelp("task-key", controller.user); + } + + @Test + void assessmentMapsServiceAuthorizationError() { + TestController controller = new TestController(student()); + controller.aiTrainingEvaluationService = mock(AiTrainingEvaluationService.class); + when(controller.aiTrainingEvaluationService.generateAssessment("task-key", controller.user)) + .thenThrow(new ServiceException(HttpStatus.FORBIDDEN, "Only students can request AI evaluations")); + + ResultEntity result = controller.assessment("task-key", mock(HttpServletRequest.class)); + + assertEquals(HttpStatus.FORBIDDEN, result.getStatusCode()); + } + + private JwtUser student() { + JwtUser user = new JwtUser(); + user.setUserId("stu-1"); + user.setRoleId(4); + return user; + } + + private static class TestController extends AiTrainingEvaluationController { + private final JwtUser user; + + private TestController(JwtUser user) { + this.user = user; + } + + @Override + protected JwtUser currentUser(HttpServletRequest request) { + return user; + } + } +} diff --git a/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java b/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java index 213b6c2..ecaeec1 100644 --- a/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java +++ b/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java @@ -2,8 +2,18 @@ package com.sztzjy.linkCommerce.service.impl; import com.sztzjy.linkCommerce.entity.AiTrainingEvaluation; import com.sztzjy.linkCommerce.entity.StudentTrainingAnswer; +import com.sztzjy.linkCommerce.entity.TeachingClassStudent; +import com.sztzjy.linkCommerce.entity.TrainingTask; +import com.sztzjy.linkCommerce.entity.dto.AiTrainingEvaluationView; +import com.sztzjy.linkCommerce.ai.QwenChatClient; import com.sztzjy.linkCommerce.mapper.AiTrainingEvaluationMapper; +import com.sztzjy.linkCommerce.mapper.StudentTrainingAnswerMapper; +import com.sztzjy.linkCommerce.mapper.TeachingClassStudentMapper; +import com.sztzjy.linkCommerce.mapper.TrainingTaskMapper; +import com.sztzjy.linkCommerce.config.exception.handler.ServiceException; +import com.sztzjy.linkCommerce.config.security.JwtUser; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -13,10 +23,128 @@ import java.util.Date; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class AiTrainingEvaluationServiceImplTest { + @Test + void rejectsBlankAllStepAnswers() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer(null, " ", "", "\t", null)); + + assertThrows(ServiceException.class, () -> service.generateHelp("new-product-survey", student())); + + verify(service.qwenChatClient, never()).completeJson(any(), any()); + } + + @Test + void helpSuccessStoresSnapshotAndReport() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", "step two", "step three", "step four")); + AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "NOT_STARTED"); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")) + .thenReturn(null, evaluation); + when(service.aiTrainingEvaluationMapper.claimHelp(eq("evaluation-1"), any(Date.class))).thenReturn(1); + when(service.qwenChatClient.completeJson(any(), any())).thenReturn(helpJson()); + + AiTrainingEvaluationView view = service.generateHelp("new-product-survey", student()); + + ArgumentCaptor saved = ArgumentCaptor.forClass(AiTrainingEvaluation.class); + verify(service.aiTrainingEvaluationMapper).completeHelp(saved.capture()); + assertEquals("SUCCEEDED", view.getHelpStatus()); + assertTrue(saved.getValue().getHelpTaskSnapshot().contains("task background")); + assertTrue(saved.getValue().getHelpTaskSnapshot().contains("objective one")); + assertTrue(saved.getValue().getHelpTaskSnapshot().contains("task requirement")); + assertTrue(saved.getValue().getHelpAnswerSnapshot().contains("step four")); + assertEquals(helpJson(), saved.getValue().getHelpReportJson()); + } + + @Test + void assessmentSuccessWritesScoreToAnswer() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "NOT_STARTED"); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")) + .thenReturn(null, evaluation); + when(service.aiTrainingEvaluationMapper.claimAssessment(eq("evaluation-1"), any(Date.class))).thenReturn(1); + when(service.qwenChatClient.completeJson(any(), any())).thenReturn(assessmentJson()); + + AiTrainingEvaluationView view = service.generateAssessment("new-product-survey", student()); + + assertEquals(Integer.valueOf(86), view.getAssessmentScore()); + verify(service.studentTrainingAnswerMapper).updateAiAssessmentScore(eq("answer-1"), eq(86), any(Date.class)); + } + + @Test + void assessmentRejectsInvalidCriteriaTotal() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "NOT_STARTED"); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")) + .thenReturn(null, evaluation); + when(service.aiTrainingEvaluationMapper.claimAssessment(eq("evaluation-1"), any(Date.class))).thenReturn(1); + when(service.qwenChatClient.completeJson(any(), any())).thenReturn(assessmentJson().replace("\"maxScore\":25", "\"maxScore\":24")); + + assertThrows(ServiceException.class, () -> service.generateAssessment("new-product-survey", student())); + + verify(service.aiTrainingEvaluationMapper).failAssessment(eq("evaluation-1"), any(), any(Date.class)); + verify(service.studentTrainingAnswerMapper, never()).updateAiAssessmentScore(any(), any(), any()); + } + + @Test + void succeededHelpReturnsStoredReportWithoutCallingQwenAgain() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + AiTrainingEvaluation evaluation = evaluation("SUCCEEDED", "NOT_STARTED"); + evaluation.setHelpReportJson(helpJson()); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")).thenReturn(evaluation); + + AiTrainingEvaluationView view = service.generateHelp("new-product-survey", student()); + + assertEquals("SUCCEEDED", view.getHelpStatus()); + assertEquals(helpJson(), view.getHelpReportJson()); + verify(service.qwenChatClient, never()).completeJson(any(), any()); + } + + @Test + void failedHelpCanBeClaimedAgain() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + AiTrainingEvaluation evaluation = evaluation("FAILED", "NOT_STARTED"); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")).thenReturn(evaluation); + when(service.aiTrainingEvaluationMapper.claimHelp(eq("evaluation-1"), any(Date.class))).thenReturn(1); + when(service.qwenChatClient.completeJson(any(), any())).thenReturn(helpJson()); + + service.generateHelp("new-product-survey", student()); + + verify(service.qwenChatClient).completeJson(any(), any()); + verify(service.aiTrainingEvaluationMapper).completeHelp(any(AiTrainingEvaluation.class)); + } + + @Test + void processingAssessmentDoesNotCallQwenAgain() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + AiTrainingEvaluation evaluation = evaluation("NOT_STARTED", "PROCESSING"); + when(service.aiTrainingEvaluationMapper.findByStudentClassAndTask("stu-1", "class-1", "task-1")).thenReturn(evaluation); + + AiTrainingEvaluationView view = service.generateAssessment("new-product-survey", student()); + + assertEquals("PROCESSING", view.getAssessmentStatus()); + verify(service.qwenChatClient, never()).completeJson(any(), any()); + } + + @Test + void rejectsNonStudentJwtUser() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + JwtUser teacher = student(); + teacher.setRoleId(2); + + assertThrows(ServiceException.class, () -> service.get("new-product-survey", teacher)); + + verify(service.qwenChatClient, never()).completeJson(any(), any()); + } + @Test void persistsIndependentHelpAndAssessmentFieldsAndAnswerScore() { AiTrainingEvaluation evaluation = new AiTrainingEvaluation(); @@ -80,4 +208,67 @@ class AiTrainingEvaluationServiceImplTest { private String readResource(String path) throws IOException { return new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8); } + + private AiTrainingEvaluationServiceImpl serviceWithContext(StudentTrainingAnswer answer) { + AiTrainingEvaluationServiceImpl service = new AiTrainingEvaluationServiceImpl(); + service.aiTrainingEvaluationMapper = mock(AiTrainingEvaluationMapper.class); + service.trainingTaskMapper = mock(TrainingTaskMapper.class); + service.studentTrainingAnswerMapper = mock(StudentTrainingAnswerMapper.class); + service.teachingClassStudentMapper = mock(TeachingClassStudentMapper.class); + service.qwenChatClient = mock(QwenChatClient.class); + when(service.trainingTaskMapper.selectByTaskKey("new-product-survey")).thenReturn(task()); + TeachingClassStudent member = new TeachingClassStudent(); + member.setTeachingClassId("class-1"); + when(service.teachingClassStudentMapper.selectActiveByStudentUserId("stu-1")).thenReturn(member); + when(service.studentTrainingAnswerMapper.selectByStudentClassAndTask("stu-1", "class-1", "task-1")).thenReturn(answer); + return service; + } + + private TrainingTask task() { + TrainingTask task = new TrainingTask(); + task.setId("task-1"); + task.setTaskKey("new-product-survey"); + task.setTaskName("New Product Survey"); + task.setBackground("task background"); + task.setObjectives("[\"objective one\"]"); + task.setRequirements("task requirement"); + return task; + } + + private StudentTrainingAnswer answer(String id, String step1, String step2, String step3, String step4) { + StudentTrainingAnswer answer = new StudentTrainingAnswer(); + answer.setId(id); + answer.setStep1Answer(step1); + answer.setStep2Answer(step2); + answer.setStep3Answer(step3); + answer.setStep4Answer(step4); + return answer; + } + + private AiTrainingEvaluation evaluation(String helpStatus, String assessmentStatus) { + AiTrainingEvaluation evaluation = new AiTrainingEvaluation(); + evaluation.setId("evaluation-1"); + evaluation.setHelpStatus(helpStatus); + evaluation.setAssessmentStatus(assessmentStatus); + return evaluation; + } + + private JwtUser student() { + JwtUser user = new JwtUser(); + user.setUserId("stu-1"); + user.setRoleId(4); + return user; + } + + private String helpJson() { + return "{\"overallDiagnosis\":\"on track\",\"strengths\":[\"clear\"],\"improvementAreas\":[{\"area\":\"detail\",\"feedback\":\"add data\"}],\"recommendedActions\":[\"revise\"]}"; + } + + private String assessmentJson() { + return "{\"score\":86,\"overallComment\":\"good\",\"criteria\":[" + + "{\"name\":\"research\",\"maxScore\":40,\"score\":34,\"rationale\":\"evidence\"}," + + "{\"name\":\"analysis\",\"maxScore\":35,\"score\":30,\"rationale\":\"logic\"}," + + "{\"name\":\"presentation\",\"maxScore\":25,\"score\":22,\"rationale\":\"clarity\"}]," + + "\"strengths\":[\"clear\"],\"improvements\":[\"more data\"]}"; + } }