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

main
chenyuan 12 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.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sztzjy.linkCommerce.ai.QwenChatClient; import com.sztzjy.linkCommerce.ai.QwenChatClient;
import com.sztzjy.linkCommerce.config.exception.handler.ServiceException; 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_HELP_QUESTION_LENGTH = 500;
private static final int MAX_STEP_CONTEXT_LENGTH = 2000; private static final int MAX_STEP_CONTEXT_LENGTH = 2000;
private static final int MAX_ASSESSMENT_RATIONALE_CHARACTERS = 200; 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(); private final ObjectMapper objectMapper = new ObjectMapper();
@ -131,6 +134,9 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
String answerSnapshot = answerSnapshot(context.answer); String answerSnapshot = answerSnapshot(context.answer);
boolean reassessment = SUCCEEDED.equals(evaluation.getAssessmentStatus()); boolean reassessment = SUCCEEDED.equals(evaluation.getAssessmentStatus());
if (reassessment && answerSnapshot.equals(evaluation.getAssessmentAnswerSnapshot())) { 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); return toView(evaluation);
} }
String taskSnapshot = taskSnapshot(context.effectiveContent); String taskSnapshot = taskSnapshot(context.effectiveContent);
@ -145,7 +151,11 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
String rawResponse = null; String rawResponse = null;
try { try {
String knowledgeContext = knowledgeContext(context, user, ""); 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); JsonNode assessmentReport = object(rawResponse);
int score = validateAssessment(assessmentReport, criterionPlan); int score = validateAssessment(assessmentReport, criterionPlan);
compactAssessmentRationales(assessmentReport); compactAssessmentRationales(assessmentReport);
@ -316,6 +326,59 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
return json(node); 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) { private String helpAnswerSnapshot(String question, String stepContext, StudentTrainingAnswer answer) {
ObjectNode node = objectMapper.createObjectNode(); ObjectNode node = objectMapper.createObjectNode();
node.put("studentQuestion", question); node.put("studentQuestion", question);
@ -359,19 +422,22 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
} }
requireText(criterion, "name"); requireText(criterion, "name");
requireText(criterion, "rationale"); requireText(criterion, "rationale");
Integer max = wholeNumber(criterion.get("maxScore"));
Integer earned = wholeNumber(criterion.get("score")); Integer earned = wholeNumber(criterion.get("score"));
// maxScore 以评分计划为准:模型偶发把 maxScore 抄错导致合计≠100,
// 不再校验模型返回的 maxScore,只用计划值判定得分范围。
if (!normalizeCriterionName(expected.name).equals(normalizeCriterionName(criterion.path("name").asText())) 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(); throw invalidReport();
} }
maxTotal += max; maxTotal += expected.maxScore;
earnedTotal += earned; 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) { private List<AssessmentCriterionPlan> assessmentCriterionPlan(String steps) {
@ -527,6 +593,8 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
+ "Return exactly " + criterionPlan.size() + " criteria in this exact order. " + "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: " + "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) + ". " + 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). " + "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. " + "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 " + "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(); 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 return "Task snapshot: " + taskSnapshot + "\nStudent answer snapshot: " + answerSnapshot
+ "\nResolved case material and this student's uploaded-file excerpts: " + knowledgeContext + "\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. " + "\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."; + "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 steps;
private final String materialName; private final String materialName;
private final String materialUrl; private final String materialUrl;
private final String stepOneQuestions;
private EffectiveTrainingTaskContent(String scope, String effectiveConfigId, String knowledge, String background, private EffectiveTrainingTaskContent(String scope, String effectiveConfigId, String knowledge, String background,
String objectives, String requirements, String steps, String materialName, String objectives, String requirements, String steps, String materialName,
String materialUrl) { String materialUrl, String stepOneQuestions) {
this.scope = scope; this.scope = scope;
this.effectiveConfigId = effectiveConfigId; this.effectiveConfigId = effectiveConfigId;
this.knowledge = knowledge; this.knowledge = knowledge;
@ -28,6 +29,7 @@ public class EffectiveTrainingTaskContent {
this.steps = steps; this.steps = steps;
this.materialName = materialName; this.materialName = materialName;
this.materialUrl = materialUrl; this.materialUrl = materialUrl;
this.stepOneQuestions = stepOneQuestions;
} }
public static EffectiveTrainingTaskContent resolve(TrainingTask task, TrainingTaskClassConfig classConfig) { 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.getRequirements() : null, task.getRequirements()),
value(useTeacher ? classConfig.getSteps() : null, task.getSteps()), value(useTeacher ? classConfig.getSteps() : null, task.getSteps()),
value(useTeacher ? classConfig.getMaterialName() : null, task.getMaterialName()), 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) { private static String value(String preferred, String fallback) {
@ -58,4 +61,5 @@ public class EffectiveTrainingTaskContent {
public String getSteps() { return steps; } public String getSteps() { return steps; }
public String getMaterialName() { return materialName; } public String getMaterialName() { return materialName; }
public String getMaterialUrl() { return materialUrl; } public String getMaterialUrl() { return materialUrl; }
public String getStepOneQuestions() { return stepOneQuestions; }
} }

Loading…
Cancel
Save