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