@ -3,7 +3,6 @@ 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 ;
@ -20,10 +19,8 @@ import com.sztzjy.linkCommerce.mapper.TeachingClassStudentMapper;
import com.sztzjy.linkCommerce.mapper.TrainingTaskMapper ;
import com.sztzjy.linkCommerce.mapper.TrainingTaskClassConfigMapper ;
import com.sztzjy.linkCommerce.service.AiTrainingEvaluationService ;
import com.sztzjy.linkCommerce.service.ScoreRankService ;
import com.sztzjy.linkCommerce.service.TrainingKnowledgeService ;
import com.sztzjy.linkCommerce.training.knowledge.EffectiveTrainingTaskContent ;
import lombok.extern.slf4j.Slf4j ;
import org.apache.commons.lang3.StringUtils ;
import org.springframework.beans.factory.annotation.Autowired ;
import org.springframework.http.HttpStatus ;
@ -33,29 +30,19 @@ 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 ;
@Slf4j
@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 String FAILURE_MESSAGE = "AI服务暂时不可用, 请稍后重试" ;
// 与 AiTrainingEvaluationMapper.xml 中 claimHelp/claimAssessment 的 INTERVAL 10 MINUTE 保持一致
private static final long PROCESSING_TIMEOUT_MILLIS = 10 * 60 * 1000L ;
private static final int LOGGED_RAW_RESPONSE_LENGTH = 500 ;
private static final int MAX_ERROR_LENGTH = 1000 ;
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 ( ) ;
@ -67,8 +54,6 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
public StudentTrainingAnswerMapper studentTrainingAnswerMapper ;
@Autowired
public TeachingClassStudentMapper teachingClassStudentMapper ;
@Autowired
public ScoreRankService scoreRankService ;
@Autowired ( required = false )
public TrainingTaskClassConfigMapper trainingTaskClassConfigMapper ;
@Autowired ( required = false )
@ -97,7 +82,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
String normalizedStepContext = normalizeStepContext ( stepContext ) ;
EvaluationContext context = resolveContext ( taskKey , user , true ) ;
AiTrainingEvaluation evaluation = getOrCreate ( context , user ) ;
if ( PROCESSING . equals ( evaluation . getHelpStatus ( ) ) & & ! isProcessingTimedOut ( evaluation ) ) {
if ( PROCESSING . equals ( evaluation . getHelpStatus ( ) ) ) {
return toView ( evaluation ) ;
}
if ( aiTrainingEvaluationMapper . claimHelp ( evaluation . getId ( ) , new Date ( ) ) ! = 1 ) {
@ -125,7 +110,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
completed . setAssessmentStatus ( evaluation . getAssessmentStatus ( ) ) ;
return toView ( completed ) ;
} catch ( RuntimeException e ) {
recordHelpFailure ( evaluation . getId ( ) , e) ;
aiTrainingEvaluationMapper. failHelp ( evaluation . getId ( ) , capp edMessage( e ) , new Date ( ) ) ;
throw asServiceException ( e ) ;
}
}
@ -134,109 +119,27 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
public AiTrainingEvaluationView generateAssessment ( String taskKey , JwtUser user ) {
EvaluationContext context = resolveContext ( taskKey , user , true ) ;
AiTrainingEvaluation evaluation = getOrCreate ( context , user ) ;
if ( PROCESSING . equals ( evaluation . getAssessmentStatus ( ) ) & & ! isProcessingTimedOut ( evaluation ) ) {
log . info ( "AI assessment still processing, evaluationId={}" , evaluation . getId ( ) ) ;
if ( PROCESSING . equals ( evaluation . getAssessmentStatus ( ) ) ) {
return toView ( evaluation ) ;
}
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 ) ;
List < AssessmentCriterionPlan > criterionPlan = assessmentCriterionPlan ( context . effectiveContent . getSteps ( ) ) ;
int claimed = reassessment
? aiTrainingEvaluationMapper . claimReassessment ( evaluation . getId ( ) , evaluation . getAssessmentAnswerSnapshot ( ) , new Date ( ) )
: aiTrainingEvaluationMapper . claimAssessment ( evaluation . getId ( ) , new Date ( ) ) ;
if ( claimed ! = 1 ) {
log . info ( "AI assessment claim skipped, evaluationId={}, reassessment={}, claimed={}" ,
evaluation . getId ( ) , reassessment , claimed ) ;
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 ) {
long startedAt = System . currentTimeMillis ( ) ;
String answerSnapshot = answerSnapshot ( context . answer ) ;
String taskSnapshot = taskSnapshot ( context . effectiveContent ) ;
List < AssessmentCriterionPlan > criterionPlan = assessmentCriterionPlan ( context . effectiveContent . getSteps ( ) ) ;
String rawResponse = null ;
try {
long knowledgeStartedAt = System . currentTimeMillis ( ) ;
String knowledgeContext = knowledgeContext ( context , actor , "" ) ;
long knowledgeCost = System . currentTimeMillis ( ) - knowledgeStartedAt ;
String choiceQuestionContext = choiceQuestionContext ( context . effectiveContent , context . answer ) ;
long modelStartedAt = System . currentTimeMillis ( ) ;
rawResponse = qwenChatClient . completeJson ( assessmentSystemPrompt ( criterionPlan ) ,
assessmentUserPrompt ( taskSnapshot , answerSnapshot , knowledgeContext , choiceQuestionContext ) ) ;
long modelCost = System . currentTimeMillis ( ) - modelStartedAt ;
// AI 评分过慢排查:分段记录知识检索与模型调用耗时(prompt 长度一并输出)
log . info ( "AI assessment timing, evaluationId={}, knowledgeContext={} ms, modelCall={} ms, total={} ms, "
+ "knowledgeContextChars={}, answerSnapshotChars={}, promptChars={}" ,
evaluation . getId ( ) , knowledgeCost , modelCost , System . currentTimeMillis ( ) - startedAt ,
knowledgeContext . length ( ) , answerSnapshot . length ( ) ,
assessmentSystemPrompt ( criterionPlan ) . length ( )
+ assessmentUserPrompt ( taskSnapshot , answerSnapshot , knowledgeContext , choiceQuestionContext ) . length ( ) ) ;
String knowledgeContext = knowledgeContext ( context , user , "" ) ;
rawResponse = qwenChatClient . completeJson ( assessmentSystemPrompt ( criterionPlan ) , assessmentUserPrompt ( taskSnapshot , answerSnapshot , knowledgeContext ) ) ;
JsonNode assessmentReport = object ( rawResponse ) ;
int score = validateAssessment ( assessmentReport , criterionPlan ) ;
compactAssessmentRationales ( assessmentReport ) ;
@ -252,12 +155,11 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
completed . setAssessmentCompletedAt ( completedAt ) ;
completed . setUpdateTime ( completedAt ) ;
completeAssessmentAndWriteScore ( completed , context . answer . getId ( ) , score , completedAt ) ;
refreshScoreRankAfterAssessment ( actor , context . teachingClassId ) ;
completed . setHelpStatus ( evaluation . getHelpStatus ( ) ) ;
completed . setAssessmentStatus ( SUCCEEDED ) ;
return toView ( completed ) ;
} catch ( RuntimeException e ) {
recordAssessmentFailure ( evaluation . getId ( ) , e , rawResponse ) ;
aiTrainingEvaluationMapper. failAssessment ( evaluation . getId ( ) , cappedMessag e(e ) , rawResponse , new Date ( ) ) ;
throw asServiceException ( e ) ;
}
}
@ -281,88 +183,6 @@ 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 comment = normalizeTeacherComment ( generateCommentOnce ( assessmentReport , helpReport , "" ) ) ;
if ( comment . codePointCount ( 0 , comment . length ( ) ) < 30 ) {
// 验收要求 30-50 字;过短时带纠正指令重写一次,取更长的那条
String instruction = " 上一次生成的评语只有" + comment . codePointCount ( 0 , comment . length ( ) )
+ "个字, 太短; 重新生成的评语必须不少于30个字、不超过50个字。" ;
String retried = normalizeTeacherComment ( generateCommentOnce ( assessmentReport , helpReport , instruction ) ) ;
if ( retried . codePointCount ( 0 , retried . length ( ) ) > comment . codePointCount ( 0 , comment . length ( ) ) ) {
comment = retried ;
}
}
return Collections . singletonMap ( "comment" , comment ) ;
}
private String generateCommentOnce ( String assessmentReport , String helpReport , String extraInstruction ) {
String rawResponse = qwenChatClient . completeJson ( commentSystemPrompt ( ) ,
commentUserPrompt ( assessmentReport , helpReport ) + StringUtils . trimToEmpty ( extraInstruction ) ) ;
JsonNode report = object ( rawResponse ) ;
JsonNode commentNode = report . get ( "comment" ) ;
if ( commentNode = = null | | ! commentNode . isTextual ( ) ) {
throw invalidReport ( ) ;
}
return 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: 30 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 ,
@ -379,40 +199,16 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
} ) ;
}
/ * *
* AI 分 数 落 库 后 立 即 重 建 本 教 学 班 的 stu_rank 当 日 快 照 ( 与 每 日 凌 晨 定 时 重 建 同 一 套 逻 辑 ) ,
* 教 师 端 成 绩 列 表 马 上 能 看 到 新 分 数 。 异 步 执 行 且 失 败 只 记 日 志 : 排 名 刷 新 失 败 不 能 影 响 评 分 结 果 。
* /
private void refreshScoreRankAfterAssessment ( JwtUser user , String teachingClassId ) {
String schoolId = user = = null ? null : user . getSchoolId ( ) ;
if ( scoreRankService = = null | | StringUtils . isAnyBlank ( schoolId , teachingClassId ) ) {
return ;
}
CompletableFuture . runAsync ( ( ) - > {
try {
if ( ! scoreRankService . doRankTeachingClass ( schoolId , teachingClassId ) ) {
scoreRankService . doRankOne ( schoolId ) ;
}
} catch ( RuntimeException e ) {
log . warn ( "AI assessment score rank refresh failed, teachingClassId={}" , teachingClassId , e ) ;
}
} ) ;
}
private AiTrainingEvaluation getOrCreate ( EvaluationContext context , JwtUser user ) {
return getOrCreate ( context , user . getUserId ( ) ) ;
}
private AiTrainingEvaluation getOrCreate ( EvaluationContext context , String studentUserId ) {
AiTrainingEvaluation evaluation = aiTrainingEvaluationMapper . findByStudentClassAndTask (
studentUserId , context . teachingClassId , context . task . getId ( ) ) ;
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 ( studentUserId ) ;
created . setStudentUserId ( user . getUserId ( ) ) ;
created . setTeachingClassId ( context . teachingClassId ) ;
created . setTaskId ( context . task . getId ( ) ) ;
created . setTaskKey ( context . task . getTaskKey ( ) ) ;
@ -422,7 +218,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
created . setUpdateTime ( now ) ;
aiTrainingEvaluationMapper . insertIgnore ( created ) ;
evaluation = aiTrainingEvaluationMapper . findByStudentClassAndTask (
studentUserId , context . teachingClassId , context . task . getId ( ) ) ;
user. getUserId ( ) , context . teachingClassId , context . task . getId ( ) ) ;
if ( evaluation = = null ) {
throw new ServiceException ( HttpStatus . SERVICE_UNAVAILABLE , "AI evaluation is temporarily unavailable" ) ;
}
@ -434,11 +230,6 @@ 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" ) ;
@ -519,59 +310,6 @@ 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 ) ;
@ -615,22 +353,19 @@ 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 ( ) ) )
| | earned = = null | | earned < 0 | | earned > expected . maxScore ) {
if ( ! expected . name . equals ( StringUtils . trimToEmpty ( criterion . path ( "name" ) . asText ( ) ) )
| | max = = null | | earned = = null | | max ! = expected . maxScore | | earned < 0 | | earned > max ) {
throw invalidReport ( ) ;
}
maxTotal + = expected. maxScore ;
maxTotal + = max;
earnedTotal + = earned ;
}
// 模型偶发把总分写错(与逐项得分之和不一致):以逐项得分为准并回写报告,
// 避免仅因算术错误整份作废。
if ( earnedTotal ! = score ) {
( ( ObjectNode ) root ) . put ( "score" , earnedTotal ) ;
if ( maxTotal ! = 100 | | earnedTotal ! = score ) {
throw invalidReport ( ) ;
}
return earnedTotal ;
return score ;
}
private List < AssessmentCriterionPlan > assessmentCriterionPlan ( String steps ) {
@ -751,16 +486,6 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
return new ServiceException ( HttpStatus . BAD_GATEWAY , "AI returned an invalid evaluation report" ) ;
}
/ * *
* 评 项 名 比 对 前 的 归 一 化 : 去 掉 模 型 从 提 示 词 的 编 号 列 表 里 照 搬 回 来 的 枚 举 前 缀
* ( 如 "1." 、 "2、" 、 "( 3) " 、 "Step 1" 、 "第2步" ) 和 所 有 空 白 。
* 没 有 编 号 + 分 隔 符 前 缀 的 纯 名 字 ( 如 "26电商2" ) 不 会 被 误 删 。
* /
private String normalizeCriterionName ( String value ) {
String name = StringUtils . trimToEmpty ( value ) . replaceAll ( "\\s+" , "" ) ;
return name . replaceFirst ( "^(?:(?i:step)\\d{0,3}[.::、)) -]*|[(( ]?第\\d{1,3}步?[)) .、:: -]*|[(( ]?\\d{1,3}[)) .、:: -]+)" , "" ) ;
}
private String helpSystemPrompt ( ) {
return "You are a training learning assistant. Return only JSON with overallDiagnosis, strengths, improvementAreas, and recommendedActions. Do not include a score." ;
}
@ -772,7 +497,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 of 30 to 50 Chinese characters ), "
+ "Required fields: score (integer total earned from criteria), overallComment (Chinese summary ), "
+ "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. "
@ -783,11 +508,8 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
+ "Determine each criterion score from its evidence first, then sum the scores. Do not choose a target total, default score or invent differences between students. "
+ "Each rationale must identify the actual evidence or omission and explain the awarded points or deduction. "
+ "There are exactly " + criterionPlan . size ( ) + " training steps and their maximum scores sum to 100. "
+ "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: "
+ "Return exactly " + criterionPlan . size ( ) + " criteria in this exact order. Each name and maxScore must exactly match this required step plan: "
+ 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 "
@ -800,17 +522,14 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ
for ( int index = 0 ; index < criterionPlan . size ( ) ; index + + ) {
AssessmentCriterionPlan item = criterionPlan . get ( index ) ;
if ( index > 0 ) result . append ( ", " ) ;
result . append ( index + 1 ) . append ( ". \" ") . append ( item . name ) . append ( "\ " (max ") . append ( item . maxScore ) . append ( ")" ) ;
result . append ( index + 1 ) . append ( ". ") . append ( item . name ) . append ( " (max ") . append ( item . maxScore ) . append ( ")" ) ;
}
return result . append ( "]" ) . toString ( ) ;
}
private String assessmentUserPrompt ( String taskSnapshot , String answerSnapshot , String knowledgeContext ,
String choiceQuestionContext ) {
private String assessmentUserPrompt ( String taskSnapshot , String answerSnapshot , String knowledgeContext ) {
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." ;
}
@ -823,87 +542,22 @@ 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 等 长 文 本 存 进 库 或 带 给 前 端 。
* /
private String storedFailureMessage ( RuntimeException exception ) {
if ( exception instanceof ServiceException & & StringUtils . isNotBlank ( exception . getMessage ( ) ) ) {
return StringUtils . left ( exception . getMessage ( ) , MAX_ERROR_LENGTH ) ;
}
return FAILURE_MESSAGE ;
}
/ * *
* 记 录 失 败 状 态 本 身 出 错 时 不 能 再 往 外 抛 : 否 则 会 盖 掉 真 正 的 失 败 原 因 ,
* 并 让 记 录 一 直 停 在 PROCESSING , 学 生 无 法 重 试 。
* /
private void recordHelpFailure ( String evaluationId , RuntimeException cause ) {
log . warn ( "AI help failed, evaluationId={}" , evaluationId , cause ) ;
try {
aiTrainingEvaluationMapper . failHelp ( evaluationId , storedFailureMessage ( cause ) , new Date ( ) ) ;
} catch ( RuntimeException recordError ) {
log . error ( "Could not record AI help failure, evaluationId={}" , evaluationId , recordError ) ;
}
}
private void recordAssessmentFailure ( String evaluationId , RuntimeException cause , String rawResponse ) {
log . warn ( "AI assessment failed, evaluationId={}" , evaluationId , cause ) ;
String message = storedFailureMessage ( cause ) ;
try {
aiTrainingEvaluationMapper . failAssessment ( evaluationId , message , rawResponse , new Date ( ) ) ;
return ;
} catch ( RuntimeException recordError ) {
log . warn ( "Could not record AI assessment failure with the raw response, retrying without it, evaluationId={}, rawResponse={}" ,
evaluationId , StringUtils . left ( rawResponse , LOGGED_RAW_RESPONSE_LENGTH ) , recordError ) ;
}
try {
aiTrainingEvaluationMapper . failAssessment ( evaluationId , message , null , new Date ( ) ) ;
} catch ( RuntimeException recordError ) {
log . error ( "Could not record AI assessment failure, evaluationId={}" , evaluationId , recordError ) ;
}
}
private boolean isProcessingTimedOut ( AiTrainingEvaluation evaluation ) {
Date updatedAt = evaluation . getUpdateTime ( ) ;
return updatedAt ! = null & & System . currentTimeMillis ( ) - updatedAt . getTime ( ) > PROCESSING_TIMEOUT_MILLIS ;
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 ) {
HttpStatus status = exception instanceof ServiceException & & ( ( ServiceException ) exception ) . getCode ( ) ! = null
? ( ( ServiceException ) exception ) . getCode ( )
: HttpStatus . BAD_GATEWAY ;
return new ServiceException ( status, FAILURE_MESSAGE ) ;
if ( exception instanceof ServiceException ) {
return ( ServiceException ) exception ;
}
return new ServiceException ( HttpStatus . BAD_GATEWAY , "AI evaluation failed" ) ;
}
private static class AssessmentCriterionPlan {