From de02726c9798b943c2c18a62ffe372a112f382d5 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Wed, 14 Oct 2026 17:19:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=E5=8A=A9=E8=AF=84=E7=BA=B3=E5=85=A5?= =?UTF-8?q?=E7=AC=AC=E4=B8=80=E6=AD=A5=E9=80=89=E6=8B=A9=E9=A2=98=E2=80=94?= =?UTF-8?q?=E2=80=94=E8=AF=84=E5=88=86=E4=B8=8A=E4=B8=8B=E6=96=87=E5=B8=A6?= =?UTF-8?q?=E9=A2=98=E5=B9=B2/=E9=80=89=E9=A1=B9/=E6=A0=87=E5=87=86?= =?UTF-8?q?=E7=AD=94=E6=A1=88/=E5=AD=A6=E7=94=9F=E4=BD=9C=E7=AD=94?= =?UTF-8?q?=EF=BC=8C=E9=94=99=E9=A2=98=E5=9C=A8=E7=AC=AC=E4=B8=80=E8=AF=84?= =?UTF-8?q?=E9=A1=B9=E6=89=A3=E5=88=86=EF=BC=9B=E6=A0=A1=E9=AA=8C=E5=AE=B9?= =?UTF-8?q?=E9=94=99=EF=BC=9A=E8=AF=84=E9=A1=B9=E5=90=8D=E5=BD=92=E4=B8=80?= =?UTF-8?q?=E5=8C=96=E3=80=81=E5=BF=BD=E7=95=A5=E6=A8=A1=E5=9E=8BmaxScore?= =?UTF-8?q?=E6=8C=89=E8=AE=A1=E5=88=92=E5=80=BC=E3=80=81=E6=80=BB=E5=88=86?= =?UTF-8?q?=E4=B8=8E=E9=80=90=E9=A1=B9=E4=B8=8D=E4=B8=80=E8=87=B4=E6=97=B6?= =?UTF-8?q?=E4=BB=A5=E9=80=90=E9=A1=B9=E4=B8=BA=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/AiTrainingEvaluationServiceImpl.java | 87 +++++++++++++++++-- .../EffectiveTrainingTaskContent.java | 8 +- 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java index c743bb9..004dd86 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java @@ -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(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."; } diff --git a/src/main/java/com/sztzjy/linkCommerce/training/knowledge/EffectiveTrainingTaskContent.java b/src/main/java/com/sztzjy/linkCommerce/training/knowledge/EffectiveTrainingTaskContent.java index 24fd0ae..9c657aa 100644 --- a/src/main/java/com/sztzjy/linkCommerce/training/knowledge/EffectiveTrainingTaskContent.java +++ b/src/main/java/com/sztzjy/linkCommerce/training/knowledge/EffectiveTrainingTaskContent.java @@ -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; } }