@ -21,9 +21,14 @@ public class JournalTaskReceiveConsumer(
{
private const int DefaultAiScoreMaxRetryCount = 3 ;
private const int DefaultAiScoreRetryDelayMilliseconds = 1000 ;
private const int DefaultAiMaxConcurrency = 1 ;
private const long DefaultMaxImageBytes = 10 * 1024 * 1024 ;
private const float DefaultCompletionThreshold = 80 ;
private const float DefaultCommunityScoreThreshold = 90 ;
private const string AiProcessingMessage = "AI批阅中, 请稍后" ;
private static readonly object AiSemaphoreLock = new ( ) ;
private static SemaphoreSlim ? aiSemaphore ;
private static int aiSemaphoreLimit ;
public string Exchange = > "ex.journal" ;
@ -54,17 +59,38 @@ public class JournalTaskReceiveConsumer(
. Where ( t = > taskIds . Contains ( t . Id ) & & ! t . IsDeleted )
. ToListAsync ( cancellationToken ) ;
var taskMap = tasks . ToDictionary ( t = > t . Id ) ;
var groupIds = tasks . Select ( t = > t . GroupId > 0 ? t . GroupId : t . Id ) . Distinct ( ) . ToList ( ) ;
var groupTasks = await client . Queryable < JournalPageTask > ( )
. Where ( t = > groupIds . Contains ( t . GroupId ) & & ! t . IsDeleted )
. ToListAsync ( cancellationToken ) ;
var persistedTaskMap = groupTasks
. GroupBy ( t = > t . GroupId > 0 ? t . GroupId : t . Id )
. ToDictionary (
g = > g . Key ,
g = > g . FirstOrDefault ( t = > t . Id = = g . Key )
? ? g . OrderBy ( t = > t . Id ) . First ( ) ) ;
var groupTaskIds = groupTasks . Select ( t = > t . Id ) . Distinct ( ) . ToList ( ) ;
var referenceAnswers = await client . Queryable < JournalPageTaskAnswer > ( )
. Where ( a = > t askIds. Contains ( a . JournalPageTaskId ) & & ! a . IsDeleted )
. Where ( a = > groupT askIds. Contains ( a . JournalPageTaskId ) & & ! a . IsDeleted )
. ToListAsync ( cancellationToken ) ;
var referenceAnswerMap = referenceAnswers
. GroupBy ( a = > a . JournalPageTaskId )
. ToDictionary ( g = > g . Key , g = > g . ToList ( ) ) ;
var page = await client . Queryable < JournalPage > ( )
. Where ( p = > p . Id = = data . PageId & & ! p . IsDeleted )
. FirstAsync ( cancellationToken ) ;
var pageIds = groupTasks . Select ( t = > t . JournalPageId ) . Append ( data . PageId ) . Distinct ( ) . ToList ( ) ;
var pages = await client . Queryable < JournalPage > ( )
. Where ( p = > pageIds . Contains ( p . Id ) & & ! p . IsDeleted )
. ToListAsync ( cancellationToken ) ;
var pageMap = pages . ToDictionary ( p = > p . Id ) ;
var existingAnswers = await client . Queryable < JournalPageTaskUserAnswer > ( )
. Where ( a = > a . UserId = = data . UserId & & groupTaskIds . Contains ( a . JournalPageTaskId ) & & ! a . IsDeleted )
. ToListAsync ( cancellationToken ) ;
var existingAnswerMap = existingAnswers
. GroupBy ( a = > a . JournalPageTaskId )
. ToDictionary ( g = > g . Key , g = > g . OrderByDescending ( a = > a . UpdatedAt ? ? a . CreatedAt ) . First ( ) ) ;
var questionContexts = new List < JournalQuestionContext > ( ) ;
var answerContexts = new List < JournalAnswerContext > ( ) ;
foreach ( var question in data . Questions )
{
@ -80,13 +106,55 @@ public class JournalTaskReceiveConsumer(
continue ;
}
referenceAnswer Map. TryGetValue ( task . Id , out var taskReferenceAnswers ) ;
var scoreResult = await ScoreQuestionAsync ( task , question , taskReferenceAnswers ? ? [ ] , cancellationToken ) ;
answerContexts . Add ( new JournalAnswerContext (
BuildAnswerEntity ( data , question , task , page , scoreResult , GetCompletionThreshold ( ) ) ,
question ,
task ,
scoreResult ) ) ;
page Map. TryGetValue ( task . JournalPage Id, out var taskPage ) ;
questionContexts . Add ( new JournalQuestionContext ( question , task , taskPage ) ) ;
}
foreach ( var scoreUnit in BuildScoreUnits ( questionContexts ) )
{
var persistedContext = BuildPersistedContext ( scoreUnit , persistedTaskMap , pageMap ) ;
if ( persistedContext = = null )
{
logger . LogWarning ( "期刊任务评分单元缺少可入库任务, UserId: {UserId}, GroupId: {GroupId}" ,
data . UserId , scoreUnit . GroupId ) ;
continue ;
}
if ( IsCompletedSameAnswer ( scoreUnit , persistedContext . Task , data , existingAnswerMap ) )
{
logger . LogInformation ( "期刊任务作答已完成且内容未变化, 跳过AI评分, UserId: {UserId}, GroupId: {GroupId}, TaskIds: {TaskIds}" ,
data . UserId , scoreUnit . GroupId , string . Join ( "," , scoreUnit . Questions . Select ( q = > q . Task . Id ) ) ) ;
continue ;
}
try
{
var scoreResult = await ScoreQuestionAsync ( scoreUnit , referenceAnswerMap , cancellationToken ) ;
var normalizedResult = NormalizeScoreResult ( scoreResult , persistedContext . Task ) ;
answerContexts . Add ( new JournalAnswerContext (
BuildAnswerEntity ( data , scoreUnit , persistedContext . Question , persistedContext . Task , persistedContext . Page , normalizedResult , GetCompletionThreshold ( ) ) ,
persistedContext . Question ,
persistedContext . Task ,
normalizedResult ) ) ;
LogSkippedGroupTasks ( scoreUnit , persistedContext . Task ) ;
}
catch ( OperationCanceledException )
{
throw ;
}
catch ( Exception ex )
{
logger . LogWarning ( ex , "期刊任务AI评分失败, 已标记为处理中, UserId: {UserId}, GroupId: {GroupId}, TaskIds: {TaskIds}" ,
data . UserId , scoreUnit . GroupId , string . Join ( "," , scoreUnit . Questions . Select ( q = > q . Task . Id ) ) ) ;
var failureResult = BuildFailureScoreResult ( ) ;
answerContexts . Add ( new JournalAnswerContext (
BuildAnswerEntity ( data , scoreUnit , persistedContext . Question , persistedContext . Task , persistedContext . Page , failureResult , GetCompletionThreshold ( ) ) ,
persistedContext . Question ,
persistedContext . Task ,
failureResult ) ) ;
LogSkippedGroupTasks ( scoreUnit , persistedContext . Task ) ;
}
}
if ( answerContexts . Count = = 0 )
@ -148,9 +216,8 @@ public class JournalTaskReceiveConsumer(
}
private async Task < JournalAnswerScoreResult > ScoreQuestionAsync (
JournalPageTask task ,
Question question ,
List < JournalPageTaskAnswer > referenceAnswers ,
JournalScoreUnit scoreUnit ,
Dictionary < long , List < JournalPageTaskAnswer > > referenceAnswerMap ,
CancellationToken cancellationToken )
{
var apiKey = configuration [ "AiChat:ApiKey" ] ;
@ -158,45 +225,61 @@ public class JournalTaskReceiveConsumer(
var model = configuration [ "AiChat:Model" ] ;
var timeoutSeconds = configuration . GetValue < int > ( "AiChat:TimeoutSeconds" ) ;
var maxTokens = configuration . GetValue < int > ( "AiChat:MaxTokens" ) ;
var temperature = configuration . GetValue < double > ( "AiChat:Temperature" ) ;
var temperature = configuration . GetValue < double? > ( "AiChat:Temperature" ) ;
if ( string . IsNullOrWhiteSpace ( apiKey ) | | string . IsNullOrWhiteSpace ( baseUrl ) | | string . IsNullOrWhiteSpace ( model ) )
{
throw new InvalidOperationException ( "AI聊天服务配置不完整, 请检查 AiChat 配置节点" ) ;
}
var answerImages = await BuildAnswerImageContentsAsync ( question , cancellationToken ) ;
if ( answerImages . Count = = 0 )
{
throw new InvalidOperationException ( $"题目 {question.Id} 缺少答案图片" ) ;
}
var referenceAnswerImages = await BuildReferenceAnswerImageContentsAsync ( task . Id , referenceAnswers , cancellationToken ) ;
var content = new List < object >
{
new
{
type = "text" ,
text = BuildScorePrompt ( task , question , answerImages . Count , referenceAnswers , referenceAnswerImages . Count )
text = BuildScorePrompt ( scoreUnit , referenceAnswerMap )
}
} ;
foreach ( var a nswerImage in answerImages )
{
content . Add ( new
{
type = "image_url" ,
image_url = new { url = answerImage . DataUrl }
} ) ;
}
if ( referenceAnswerImages . Count > 0 )
var totalA nswerImageCount = 0 ;
foreach ( var context in scoreUnit . Questions )
{
var answerImages = await BuildAnswerImageContentsAsync ( context . Question , cancellationToken ) ;
totalAnswerImageCount + = answerImages . Count ;
content . Add ( new
{
type = "text" ,
text = $"以下为参考答案图片,共 {referenceAnswerImages.Count} 张。参考答案不是必有, 评分时以题目Prompt和学生答案为主 。"
text = $"以下为任务 {context.Task.Id} 的学生作答图片,共 {answerImages.Count} 张 。"
} ) ;
foreach ( var answerImage in answerImages )
{
content . Add ( new
{
type = "image_url" ,
image_url = new { url = answerImage . DataUrl }
} ) ;
}
}
if ( totalAnswerImageCount = = 0 )
{
throw new InvalidOperationException ( $"评分单元 {scoreUnit.GroupId} 缺少答案图片" ) ;
}
foreach ( var context in scoreUnit . Questions )
{
referenceAnswerMap . TryGetValue ( context . Task . Id , out var referenceAnswers ) ;
var referenceAnswerImages = await BuildReferenceAnswerImageContentsAsync ( context . Task . Id , referenceAnswers ? ? [ ] , cancellationToken ) ;
if ( referenceAnswerImages . Count = = 0 )
{
continue ;
}
content . Add ( new
{
type = "text" ,
text = $"以下为任务 {context.Task.Id} 的参考答案图片,共 {referenceAnswerImages.Count} 张。参考答案不是必有, 评分时以题目Prompt和学生答案为主。"
} ) ;
foreach ( var referenceAnswerImage in referenceAnswerImages )
@ -226,7 +309,7 @@ public class JournalTaskReceiveConsumer(
}
} ,
max_tokens = maxTokens > 0 ? maxTokens : 2000 ,
temperature = temperature > 0 ? temperature : 0.2 ,
temperature = temperature is > = 0 ? temperature . Value : 0.1 ,
stream = false ,
response_format = new { type = "json_object" }
} ;
@ -236,35 +319,46 @@ public class JournalTaskReceiveConsumer(
DefaultIgnoreCondition = JsonIgnoreCondition . WhenWritingNull
} ) ;
var responseContent = await SendAiScoreRequestWithRetryAsync (
task . Id ,
$"{baseUrl.TrimEnd('/')}/chat/completions" ,
apiKey ,
requestJson ,
timeoutSeconds > 0 ? timeoutSeconds : 300 ,
cancellationToken ) ;
var resultJson = ExtractAssistantContent ( responseContent ) ;
var scoreResult = ParseScoreResult ( resul tJson) ;
scoreResult . Result = TrimResult ( scoreResult . Result ) ;
return scoreResult ;
var semaphore = GetAiSemaphore ( ) ;
await semaphore . WaitAsync ( cancellationToken ) ;
try
{
var scoreResult = await SendAiScoreRequestWithRetryAsync (
scoreUnit . GroupId ,
$"{baseUrl.TrimEnd('/')}/chat/completions" ,
apiKey ,
reques tJson ,
timeoutSeconds > 0 ? timeoutSeconds : 300 ,
cancellationToken ) ;
scoreResult . Result = TrimResult ( scoreResult . Result ) ;
return scoreResult ;
}
finally
{
semaphore . Release ( ) ;
}
}
private static string BuildScorePrompt (
JournalPageTask task ,
Question question ,
int answerImageCount ,
List < JournalPageTaskAnswer > referenceAnswers ,
int referenceAnswerImageCount )
JournalScoreUnit scoreUnit ,
Dictionary < long , List < JournalPageTaskAnswer > > referenceAnswerMap )
{
var referenceAnswerTexts = referenceAnswer s
var referenceAnswerTexts = scoreUnit . Question s
. SelectMany ( q = >
{
referenceAnswerMap . TryGetValue ( q . Task . Id , out var answers ) ;
return answers ? ? [ ] ;
} )
. Select ( a = > a . Answer ? . Trim ( ) )
. Where ( a = > ! string . IsNullOrWhiteSpace ( a ) )
. Distinct ( )
. ToList ( ) ;
var prompt = new StringBuilder ( ) ;
prompt . AppendLine ( scoreUnit . Questions . Count > 1
? "这是同一跨页题目的多页作答,请综合全部学生作答图片评分。"
: "这是单页题目作答,请根据学生作答图片评分。" ) ;
prompt . AppendLine ( "参考答案不是必有;若无参考答案,以题目 Prompt 和学生答案为准评分。" ) ;
prompt . AppendLine ( $"参考答案图片数量:{referenceAnswerImageCount}" ) ;
if ( referenceAnswerTexts . Count > 0 )
{
prompt . AppendLine ( "参考答案文本:" ) ;
@ -278,21 +372,27 @@ public class JournalTaskReceiveConsumer(
prompt . AppendLine ( "参考答案文本:无" ) ;
}
prompt . AppendLine ( ) ;
prompt . AppendLine ( "请根据题目评分 Prompt 和学生答案图片进行评分。 " ) ;
prompt . AppendLine ( "题目信息: " ) ;
foreach ( var context in scoreUnit . Questions )
{
prompt . AppendLine ( $"- TaskId: {context.Task.Id}" ) ;
prompt . AppendLine ( $" 页码:{context.Page?.PageNum ?? 0}" ) ;
prompt . AppendLine ( $" 题号:{context.Task.No}" ) ;
prompt . AppendLine ( $" 题目内容:{context.Task.Task}" ) ;
prompt . AppendLine ( $" 评分Prompt: {context.Task.Prompt}" ) ;
prompt . AppendLine ( $" 成长值上限:{context.Task.GrowthPoint}" ) ;
prompt . AppendLine ( $" 积分上限:{context.Task.Points}" ) ;
prompt . AppendLine ( $" 理解力上限:{context.Task.Comprehension}" ) ;
prompt . AppendLine ( $" 判断力上限:{context.Task.Judgment}" ) ;
prompt . AppendLine ( $" 表达力上限:{context.Task.Expression}" ) ;
prompt . AppendLine ( $" 说服力上限:{context.Task.Persuasiveness}" ) ;
}
prompt . AppendLine ( ) ;
prompt . AppendLine ( $"题目内容:{task.Task} ") ;
prompt . AppendLine ( "评分Prompt: " ) ;
prompt . AppendLine ( task . Prompt ) ;
prompt . AppendLine ( ) ;
prompt . AppendLine ( "题目配置: " ) ;
prompt . AppendLine ( $"- 成长值上限:{task.GrowthPoint}" ) ;
prompt . AppendLine ( $"- 积分上限:{task.Points}" ) ;
prompt . AppendLine ( $"- 理解力上限:{task.Comprehension}" ) ;
prompt . AppendLine ( $"- 判断力上限:{task.Judgment}" ) ;
prompt . AppendLine ( $"- 表达力上限:{task.Expression}" ) ;
prompt . AppendLine ( $"- 说服力上限:{task.Persuasiveness}" ) ;
prompt . AppendLine ( ) ;
prompt . AppendLine ( $"学生答案图片数量:{answerImageCount}" ) ;
prompt . AppendLine ( "评分要求: ") ;
prompt . AppendLine ( "- 不得超过题目配置中的各项上限。 " ) ;
prompt . AppendLine ( "- 看不清、缺页、无法识别或答案明显不完整时,降低 Completion, 不要猜测高分。" ) ;
prompt . AppendLine ( "- Completion 表示作答完整度,范围 0-100。" ) ;
prompt . AppendLine ( "- Result 返回 50 字内中文评语。 " ) ;
prompt . AppendLine ( ) ;
prompt . AppendLine ( "只返回如下 JSON 字段:" ) ;
prompt . AppendLine ( "{" ) ;
@ -408,7 +508,7 @@ public class JournalTaskReceiveConsumer(
return result ;
}
private async Task < string > SendAiScoreRequestWithRetryAsync (
private async Task < JournalAnswerScoreResult > SendAiScoreRequestWithRetryAsync (
long taskId ,
string requestUrl ,
string apiKey ,
@ -446,22 +546,37 @@ public class JournalTaskReceiveConsumer(
var responseContent = await response . Content . ReadAsStringAsync ( cancellationToken ) ;
if ( response . IsSuccessStatusCode )
{
return responseContent ;
var resultJson = ExtractAssistantContent ( responseContent ) ;
return ParseScoreResult ( resultJson ) ;
}
logger . LogWarning ( "AI评分调用失败, TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}, StatusCode: {StatusCode}, Response: {Response}" ,
taskId , attempt , maxRetryCount , response . StatusCode , responseContent ) ;
if ( ! ShouldRetry ( response . StatusCode ) | | attempt = = maxRetryCount )
if ( ! ShouldRetry ( response . StatusCode ) )
{
throw new NonRetryAiScoreException ( $"AI评分调用失败: {response.StatusCode},响应:{responseContent}" ) ;
}
if ( attempt = = maxRetryCount )
{
throw new InvalidOperationException ( $"AI评分调用失败: {response.StatusCode},响应:{responseContent}" ) ;
}
}
catch ( OperationCanceledException )
catch ( OperationCanceledException ) when ( cancellationToken . IsCancellationRequested )
{
throw ;
}
catch ( Exception ex ) when ( attempt < maxRetryCount )
catch ( OperationCanceled Exception ex ) when ( attempt < maxRetryCount )
{
lastException = ex ;
logger . LogWarning ( ex , "AI评分调用超时, 准备重试, TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}" , taskId , attempt , maxRetryCount ) ;
}
catch ( OperationCanceledException ex )
{
throw new InvalidOperationException ( $"AI评分调用超时, TaskId: {taskId}" , ex ) ;
}
catch ( Exception ex ) when ( attempt < maxRetryCount & & ex is not NonRetryAiScoreException )
{
lastException = ex ;
logger . LogWarning ( ex , "AI评分调用异常, 准备重试, TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}" , taskId , attempt , maxRetryCount ) ;
@ -536,6 +651,7 @@ public class JournalTaskReceiveConsumer(
private static JournalAnswerScoreResult ParseScoreResult ( string resultJson )
{
var cleanedJson = CleanJsonContent ( resultJson ) ;
EnsureRequiredScoreFields ( cleanedJson ) ;
var result = JsonSerializer . Deserialize < JournalAnswerScoreResult > ( cleanedJson , new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
@ -572,8 +688,219 @@ public class JournalTaskReceiveConsumer(
return text . Trim ( ) ;
}
private static void EnsureRequiredScoreFields ( string json )
{
using var document = JsonDocument . Parse ( json ) ;
var requiredFields = new [ ]
{
nameof ( JournalAnswerScoreResult . Score ) ,
nameof ( JournalAnswerScoreResult . GrowthPoint ) ,
nameof ( JournalAnswerScoreResult . Points ) ,
nameof ( JournalAnswerScoreResult . Comprehension ) ,
nameof ( JournalAnswerScoreResult . Judgment ) ,
nameof ( JournalAnswerScoreResult . Expression ) ,
nameof ( JournalAnswerScoreResult . Persuasiveness ) ,
nameof ( JournalAnswerScoreResult . Completion ) ,
nameof ( JournalAnswerScoreResult . Result )
} ;
foreach ( var field in requiredFields )
{
if ( ! document . RootElement . EnumerateObject ( ) . Any ( p = > string . Equals ( p . Name , field , StringComparison . OrdinalIgnoreCase ) ) )
{
throw new InvalidOperationException ( $"AI评分结果缺少字段: {field}" ) ;
}
}
}
private static JournalAnswerScoreResult NormalizeScoreResult ( JournalAnswerScoreResult scoreResult , JournalPageTask task )
{
var scoreMax = task . Comprehension + task . Judgment + task . Expression + task . Persuasiveness ;
if ( scoreMax < = 0 )
{
scoreMax = 100 ;
}
return new JournalAnswerScoreResult
{
Score = Clamp ( scoreResult . Score , 0 , scoreMax ) ,
GrowthPoint = ( int ) Clamp ( scoreResult . GrowthPoint , 0 , task . GrowthPoint ) ,
Points = ( int ) Clamp ( scoreResult . Points , 0 , task . Points ) ,
Comprehension = Clamp ( scoreResult . Comprehension , 0 , task . Comprehension ) ,
Judgment = Clamp ( scoreResult . Judgment , 0 , task . Judgment ) ,
Expression = Clamp ( scoreResult . Expression , 0 , task . Expression ) ,
Persuasiveness = Clamp ( scoreResult . Persuasiveness , 0 , task . Persuasiveness ) ,
Completion = Clamp ( scoreResult . Completion , 0 , 100 ) ,
Result = TrimResult ( scoreResult . Result )
} ;
}
private static float Clamp ( float value , float min , float max )
{
if ( float . IsNaN ( value ) | | float . IsInfinity ( value ) )
{
return min ;
}
if ( max < min )
{
max = min ;
}
return Math . Min ( Math . Max ( value , min ) , max ) ;
}
private static JournalAnswerScoreResult BuildFailureScoreResult ( )
{
return new JournalAnswerScoreResult
{
Completion = 0 ,
Result = AiProcessingMessage
} ;
}
private static List < JournalScoreUnit > BuildScoreUnits ( List < JournalQuestionContext > contexts )
{
return contexts
. GroupBy ( c = > c . Task . GroupId > 0 ? c . Task . GroupId : c . Task . Id )
. Select ( g = > new JournalScoreUnit (
g . Key ,
g . OrderBy ( c = > c . Page ? . PageNum ? ? 0 )
. ThenBy ( c = > ParseTaskNo ( c . Task . No ) )
. ThenBy ( c = > c . Task . Id )
. ToList ( ) ) )
. ToList ( ) ;
}
private static JournalQuestionContext ? BuildPersistedContext (
JournalScoreUnit scoreUnit ,
Dictionary < long , JournalPageTask > persistedTaskMap ,
Dictionary < long , JournalPage > pageMap )
{
if ( scoreUnit . Questions . Count = = 0 )
{
return null ;
}
var sourceContext = scoreUnit . Questions . FirstOrDefault ( q = > q . Task . Id = = scoreUnit . GroupId )
? ? scoreUnit . Questions . First ( ) ;
var persistedTask = persistedTaskMap . TryGetValue ( scoreUnit . GroupId , out var task )
? task
: sourceContext . Task ;
pageMap . TryGetValue ( persistedTask . JournalPageId , out var persistedPage ) ;
return new JournalQuestionContext ( sourceContext . Question , persistedTask , persistedPage ) ;
}
private static int ParseTaskNo ( string? taskNo )
{
return int . TryParse ( taskNo , out var no ) ? no : int . MaxValue ;
}
private static bool IsCompletedSameAnswer (
JournalScoreUnit scoreUnit ,
JournalPageTask persistedTask ,
QuestionData data ,
Dictionary < long , JournalPageTaskUserAnswer > existingAnswerMap )
{
return scoreUnit . Questions . Count > 0
& & existingAnswerMap . TryGetValue ( persistedTask . Id , out var existing )
& & existing . Status = = ( int ) UserAnswerStatusEnum . Complete
& & string . Equals ( existing . PageAnswerUrl ? ? string . Empty , data . PageAnswerUrl ? ? string . Empty , StringComparison . Ordinal )
& & string . Equals ( existing . AnswerUrl ? ? string . Empty , SerializeAnswerUrls ( scoreUnit ) , StringComparison . Ordinal )
& & existing . AnswerStartTime = = GetAnswerStartTime ( scoreUnit )
& & existing . AnswerEndTime = = GetAnswerEndTime ( scoreUnit ) ;
}
private SemaphoreSlim GetAiSemaphore ( )
{
var maxConcurrency = configuration . GetValue < int > ( "AiChat:MaxConcurrency" ) ;
if ( maxConcurrency < = 0 )
{
maxConcurrency = DefaultAiMaxConcurrency ;
}
lock ( AiSemaphoreLock )
{
if ( aiSemaphore = = null | | aiSemaphoreLimit ! = maxConcurrency )
{
aiSemaphore = new SemaphoreSlim ( maxConcurrency , maxConcurrency ) ;
aiSemaphoreLimit = maxConcurrency ;
}
return aiSemaphore ;
}
}
private void LogSkippedGroupTasks ( JournalScoreUnit scoreUnit , JournalPageTask persistedTask )
{
var skippedTaskIds = scoreUnit . Questions
. Select ( q = > q . Task . Id )
. Where ( id = > id ! = persistedTask . Id )
. Distinct ( )
. ToList ( ) ;
if ( skippedTaskIds . Count = = 0 )
{
return ;
}
logger . LogInformation ( "期刊跨页题评分结果仅保存到主任务, GroupId: {GroupId}, PersistedTaskId: {PersistedTaskId}, SkippedTaskIds: {SkippedTaskIds}" ,
scoreUnit . GroupId , persistedTask . Id , string . Join ( "," , skippedTaskIds ) ) ;
}
private static string SerializeAnswerUrls ( JournalScoreUnit scoreUnit )
{
var answerUrls = scoreUnit . Questions
. SelectMany ( q = > q . Question . AnswerUrl ? ? [ ] )
. Where ( url = > ! string . IsNullOrWhiteSpace ( url ) )
. Distinct ( )
. ToArray ( ) ;
return JsonSerializer . Serialize ( answerUrls ) ;
}
private static DateTime GetAnswerStartTime ( JournalScoreUnit scoreUnit )
{
var startTimes = scoreUnit . Questions
. Select ( q = > q . Question . AnswerStartTime )
. Where ( t = > t ! = default )
. ToList ( ) ;
return startTimes . Count = = 0 ? default : startTimes . Min ( ) ;
}
private static DateTime GetAnswerEndTime ( JournalScoreUnit scoreUnit )
{
var endTimes = scoreUnit . Questions
. Select ( q = > q . Question . AnswerEndTime )
. Where ( t = > t ! = default )
. ToList ( ) ;
return endTimes . Count = = 0 ? default : endTimes . Max ( ) ;
}
private static int GetAnswerSeconds ( JournalScoreUnit scoreUnit )
{
return scoreUnit . Questions . Sum ( q = > Math . Max ( 0 , q . Question . AnswerTime ) ) ;
}
private static int GetBreakCount ( JournalScoreUnit scoreUnit )
{
return scoreUnit . Questions . Sum ( q = > Math . Max ( 0 , q . Question . BreakCount ) ) ;
}
private static string SerializeBreakTimes ( JournalScoreUnit scoreUnit )
{
var breakTimes = scoreUnit . Questions
. SelectMany ( q = > q . Question . BreakTimes ? ? [ ] )
. ToList ( ) ;
return JsonSerializer . Serialize ( breakTimes ) ;
}
private static JournalPageTaskUserAnswer BuildAnswerEntity (
QuestionData data ,
JournalScoreUnit scoreUnit ,
Question question ,
JournalPageTask task ,
JournalPage ? page ,
@ -603,12 +930,12 @@ public class JournalTaskReceiveConsumer(
Expression = Math . Max ( 0 , scoreResult . Expression ) ,
Persuasiveness = Math . Max ( 0 , scoreResult . Persuasiveness ) ,
QuestionAnswerUrl = question . Url ,
AnswerUrl = Json Serializer . Serialize ( question . AnswerUrl ? ? [ ] ) ,
AnswerUrl = SerializeAnswerUrls ( scoreUnit ) ,
PageAnswerUrl = data . PageAnswerUrl ,
Revision = 0 ,
AnswerStartTime = question . AnswerStartTime,
AnswerEndTime = question . AnswerEndTime ,
AnswerSeconds = question . AnswerTime ,
AnswerStartTime = Get AnswerStartTime( scoreUnit ) ,
AnswerEndTime = GetAnswerEndTime ( scoreUnit ) ,
AnswerSeconds = GetAnswerSeconds ( scoreUnit ) ,
ImageRecognition = 0 ,
JournalPageNum = page ? . PageNum ? ? 0 ,
Modify = 0 ,
@ -618,8 +945,8 @@ public class JournalTaskReceiveConsumer(
Type = task . Type . ToString ( ) ,
DotPageNo = page ? . PageNo ? ? string . Empty ,
PageAnswerDotUrl = string . Empty ,
BreakCount = question . BreakCount ,
BreakTimes = Json Serializer . Serialize ( question . BreakTimes ? ? [ ] ) ,
BreakCount = GetBreakCount ( scoreUnit ) ,
BreakTimes = SerializeBreakTimes ( scoreUnit ) ,
AssignmentStatus = answerStatus . ToString ( ) ,
Status = ( int ) answerStatus ,
CreatedBy = data . UserId . ToString ( ) ,
@ -911,4 +1238,18 @@ public record JournalAnswerContext(
JournalPageTask Task ,
JournalAnswerScoreResult ScoreResult ) ;
public record JournalQuestionContext (
Question Question ,
JournalPageTask Task ,
JournalPage ? Page ) ;
public record JournalScoreUnit (
long GroupId ,
List < JournalQuestionContext > Questions ) ;
public record AnswerImageContent ( string DataUrl ) ;
/// <summary>
/// AI评分不可重试异常。
/// </summary>
public class NonRetryAiScoreException ( string message ) : Exception ( message ) ;