feat: add AI help and assessment APIs
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue