feat: AI助评纳入第一步选择题——评分上下文带题干/选项/标准答案/学生作答,错题在第一评项扣分;校验容错:评项名归一化、忽略模型maxScore按计划值、总分与逐项不一致时以逐项为准

main
chenyuan 10 hours ago
parent 8fd2aabbba
commit de02726c97

@ -3,6 +3,7 @@ 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.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sztzjy.linkCommerce.ai.QwenChatClient;
import com.sztzjy.linkCommerce.config.exception.handler.ServiceException;
@ -49,6 +50,8 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
private static final int MAX_HELP_QUESTION_LENGTH = 500;
private static final int MAX_STEP_CONTEXT_LENGTH = 2000;
private static final int MAX_ASSESSMENT_RATIONALE_CHARACTERS = 200;
private static final int MAX_CHOICE_TITLE_LENGTH = 120;
private static final int MAX_CHOICE_OPTION_LENGTH = 80;
private final ObjectMapper objectMapper = new ObjectMapper();
@ -131,6 +134,9 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
String answerSnapshot = answerSnapshot(context.answer);
boolean reassessment = SUCCEEDED.equals(evaluation.getAssessmentStatus());
if (reassessment && answerSnapshot.equals(evaluation.getAssessmentAnswerSnapshot())) {
log.info("AI assessment cached hit, evaluationId={}, answerId={}, snapshotLength={}",
evaluation.getId(), context.answer == null ? null : context.answer.getId(),
answerSnapshot.length());
return toView(evaluation);
}
String taskSnapshot = taskSnapshot(context.effectiveContent);
@ -145,7 +151,11 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
String rawResponse = null;
try {
String knowledgeContext = knowledgeContext(context, user, "");
rawResponse = qwenChatClient.completeJson(assessmentSystemPrompt(criterionPlan), assessmentUserPrompt(taskSnapshot, answerSnapshot, knowledgeContext));
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);
int score = validateAssessment(assessmentReport, criterionPlan);
compactAssessmentRationales(assessmentReport);
@ -316,6 +326,59 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
return json(node);
}
/**
* 第一步选择题的评分上下文:题干、选项文字、标准答案与学生作答逐题对照。
* 只用于 AI 助评——助学的提示词面向学生,带标准答案会泄题。
*/
private String choiceQuestionContext(EffectiveTrainingTaskContent content, StudentTrainingAnswer answer) {
ArrayNode result = objectMapper.createArrayNode();
JsonNode questions = parseJsonArray(content.getStepOneQuestions());
JsonNode choiceAnswers = parseChoiceAnswers(answer == null ? null : answer.getStep1Answer());
for (JsonNode question : questions) {
ObjectNode entry = result.addObject();
entry.put("title", StringUtils.abbreviate(question.path("title").asText(), MAX_CHOICE_TITLE_LENGTH));
ArrayNode options = entry.putArray("options");
for (JsonNode option : question.path("options")) {
ObjectNode item = options.addObject();
item.put("key", option.path("key").asText());
item.put("content", StringUtils.abbreviate(option.path("content").asText(), MAX_CHOICE_OPTION_LENGTH));
}
entry.put("correctAnswer", question.path("answer").asText());
entry.put("studentAnswer", choiceAnswers.path(question.path("id").asText()).asText(""));
}
return json(result);
}
private int choiceQuestionEntryCount(String choiceQuestionContext) {
try {
JsonNode node = objectMapper.readTree(StringUtils.trimToEmpty(choiceQuestionContext));
return node.isArray() ? node.size() : 0;
} catch (JsonProcessingException | RuntimeException e) {
return 0;
}
}
private JsonNode parseJsonArray(String value) {
try {
String normalized = StringUtils.trimToEmpty(value);
if (!normalized.startsWith("[")) return objectMapper.createArrayNode();
JsonNode node = objectMapper.readTree(normalized);
return node != null && node.isArray() ? node : objectMapper.createArrayNode();
} catch (JsonProcessingException | RuntimeException e) {
return objectMapper.createArrayNode();
}
}
private JsonNode parseChoiceAnswers(String step1Answer) {
try {
if (StringUtils.isBlank(step1Answer)) return objectMapper.createObjectNode();
JsonNode choiceAnswers = objectMapper.readTree(step1Answer).path("choiceAnswers");
return choiceAnswers.isObject() ? choiceAnswers : objectMapper.createObjectNode();
} catch (JsonProcessingException | RuntimeException e) {
return objectMapper.createObjectNode();
}
}
private String helpAnswerSnapshot(String question, String stepContext, StudentTrainingAnswer answer) {
ObjectNode node = objectMapper.createObjectNode();
node.put("studentQuestion", question);
@ -359,19 +422,22 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
}
requireText(criterion, "name");
requireText(criterion, "rationale");
Integer max = wholeNumber(criterion.get("maxScore"));
Integer earned = wholeNumber(criterion.get("score"));
// maxScore 以评分计划为准:模型偶发把 maxScore 抄错导致合计≠100,
// 不再校验模型返回的 maxScore,只用计划值判定得分范围。
if (!normalizeCriterionName(expected.name).equals(normalizeCriterionName(criterion.path("name").asText()))
|| max == null || earned == null || max != expected.maxScore || earned < 0 || earned > max) {
|| earned == null || earned < 0 || earned > expected.maxScore) {
throw invalidReport();
}
maxTotal += max;
maxTotal += expected.maxScore;
earnedTotal += earned;
}
if (maxTotal != 100 || earnedTotal != score) {
throw invalidReport();
// 模型偶发把总分写错(与逐项得分之和不一致):以逐项得分为准并回写报告,
// 避免仅因算术错误整份作废。
if (earnedTotal != score) {
((ObjectNode) root).put("score", earnedTotal);
}
return score;
return earnedTotal;
}
private List<AssessmentCriterionPlan> assessmentCriterionPlan(String steps) {
@ -527,6 +593,8 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
+ "Return exactly " + criterionPlan.size() + " criteria in this exact order. "
+ "Each criterion's name must be copied verbatim from the quoted name in this required step plan, without adding any numbering or other characters; maxScore must exactly match: "
+ criterionPlanPrompt(criterionPlan) + ". "
+ "The user prompt may include a choiceQuestions array: grade each pick strictly against its correctAnswer; "
+ "every wrong or unanswered pick must lose points within the first criterion's score, and that criterion's rationale must name the wrong question numbers. "
+ "score, maxScore, and each criterion score must be JSON integer numbers (not strings). "
+ "Write one complete concise Chinese sentence for each rationale and end it with Chinese punctuation. "
+ "The combined text length of all criteria.rationale values must not exceed 200 Chinese characters; target no more than "
@ -544,9 +612,12 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
return result.append("]").toString();
}
private String assessmentUserPrompt(String taskSnapshot, String answerSnapshot, String knowledgeContext) {
private String assessmentUserPrompt(String taskSnapshot, String answerSnapshot, String knowledgeContext,
String choiceQuestionContext) {
return "Task snapshot: " + taskSnapshot + "\nStudent answer snapshot: " + answerSnapshot
+ "\nResolved case material and this student's uploaded-file excerpts: " + knowledgeContext
+ "\nchoiceQuestions (step-one single-choice questions): " + choiceQuestionContext
+ " Each entry pairs a question's title and options with its correctAnswer and this student's studentAnswer; an empty studentAnswer means the question was not answered."
+ "\nIf the answer snapshot contains a server-generated calculationCheck object, treat it as verified calculation evidence. "
+ "When calculationCorrect is false, deduct points for the reported incorrectItemCount rather than treating the calculation as correct.";
}

@ -15,10 +15,11 @@ public class EffectiveTrainingTaskContent {
private final String steps;
private final String materialName;
private final String materialUrl;
private final String stepOneQuestions;
private EffectiveTrainingTaskContent(String scope, String effectiveConfigId, String knowledge, String background,
String objectives, String requirements, String steps, String materialName,
String materialUrl) {
String materialUrl, String stepOneQuestions) {
this.scope = scope;
this.effectiveConfigId = effectiveConfigId;
this.knowledge = knowledge;
@ -28,6 +29,7 @@ public class EffectiveTrainingTaskContent {
this.steps = steps;
this.materialName = materialName;
this.materialUrl = materialUrl;
this.stepOneQuestions = stepOneQuestions;
}
public static EffectiveTrainingTaskContent resolve(TrainingTask task, TrainingTaskClassConfig classConfig) {
@ -42,7 +44,8 @@ public class EffectiveTrainingTaskContent {
value(useTeacher ? classConfig.getRequirements() : null, task.getRequirements()),
value(useTeacher ? classConfig.getSteps() : null, task.getSteps()),
value(useTeacher ? classConfig.getMaterialName() : null, task.getMaterialName()),
value(useTeacher ? classConfig.getMaterialUrl() : null, task.getMaterialUrl()));
value(useTeacher ? classConfig.getMaterialUrl() : null, task.getMaterialUrl()),
value(useTeacher ? classConfig.getStepOneQuestions() : null, task.getStepOneQuestions()));
}
private static String value(String preferred, String fallback) {
@ -58,4 +61,5 @@ public class EffectiveTrainingTaskContent {
public String getSteps() { return steps; }
public String getMaterialName() { return materialName; }
public String getMaterialUrl() { return materialUrl; }
public String getStepOneQuestions() { return stepOneQuestions; }
}

Loading…
Cancel
Save