feat: add AI help and assessment APIs

main
chenyuan 1 month ago
parent 60a2b1dfcd
commit 2d6efbe9ea

@ -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.

@ -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<AiTrainingEvaluationView> 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<AiTrainingEvaluationView> 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<AiTrainingEvaluationView> 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);
}
}

@ -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; }
}

@ -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);
}

@ -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;
}
}
}

@ -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<AiTrainingEvaluationView> 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<AiTrainingEvaluationView> 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;
}
}
}

@ -2,8 +2,18 @@ package com.sztzjy.linkCommerce.service.impl;
import com.sztzjy.linkCommerce.entity.AiTrainingEvaluation; import com.sztzjy.linkCommerce.entity.AiTrainingEvaluation;
import com.sztzjy.linkCommerce.entity.StudentTrainingAnswer; 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.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.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; 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.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 { 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<AiTrainingEvaluation> 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 @Test
void persistsIndependentHelpAndAssessmentFieldsAndAnswerScore() { void persistsIndependentHelpAndAssessmentFieldsAndAnswerScore() {
AiTrainingEvaluation evaluation = new AiTrainingEvaluation(); AiTrainingEvaluation evaluation = new AiTrainingEvaluation();
@ -80,4 +208,67 @@ class AiTrainingEvaluationServiceImplTest {
private String readResource(String path) throws IOException { private String readResource(String path) throws IOException {
return new String(Files.readAllBytes(Paths.get(path)), StandardCharsets.UTF_8); 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\"]}";
}
} }

Loading…
Cancel
Save