using Hangfire; using QYZH.InteractiveMagazine.Infrastructure.OSS; using QYZH.InteractiveMagazine.Models.Entity; using QYZH.InteractiveMagazine.Models.Enum; using QYZH.InteractiveMagazine.WorkService.Consumers; using SqlSugar; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace QYZH.InteractiveMagazine.WorkService.Jobs; /// /// 期刊任务AI批改定时任务。 /// public class JournalTaskAiScoreJob( ILogger logger, IConfiguration configuration, IServiceScopeFactory scopeFactory, IHttpClientFactory httpClientFactory, OssService ossService) { private const int DefaultPendingAnswerMinutes = 30; private const int DefaultPendingAnswerBatchSize = 100; private const int DefaultAiScoreMaxRetryCount = 3; private const int DefaultAiScoreRetryDelayMilliseconds = 1000; private const int DefaultAiMaxConcurrency = 1; private const long DefaultMaxImageBytes = 10 * 1024 * 1024; private const string InvalidAnswerResult = "作答内容不符合要求"; private const string AiProcessingMessage = "AI批阅中,请稍后"; private static readonly object AiSemaphoreLock = new(); private static readonly object RunWindowLock = new(); private static SemaphoreSlim? aiSemaphore; private static int aiSemaphoreLimit; private static DateTime? lastRunAt; /// /// 执行AI批改。 /// [DisableConcurrentExecution(1800)] public void Execute() { ExecuteAsync().GetAwaiter().GetResult(); } /// /// 异步执行AI批改。 /// public async Task ExecuteAsync() { using var scope = scopeFactory.CreateScope(); var client = scope.ServiceProvider.GetRequiredService(); var windowEnd = DateTime.Now; var windowStart = GetWindowStart(windowEnd); var batchSize = GetPendingAnswerBatchSize(); var pendingAnswers = await client.Queryable() .Where(a => !a.IsDeleted && a.Status == (int)UserAnswerStatusEnum.Processing && ((a.UpdatedAt != null && a.UpdatedAt > windowStart && a.UpdatedAt <= windowEnd) || (a.UpdatedAt == null && a.CreatedAt > windowStart && a.CreatedAt <= windowEnd))) .OrderBy(a => a.UpdatedAt, OrderByType.Asc) .OrderBy(a => a.CreatedAt, OrderByType.Asc) .Take(batchSize) .ToListAsync(); if (pendingAnswers.Count == 0) { SetLastRunAt(windowEnd); logger.LogInformation("期刊AI批改任务没有待处理答案,WindowStart: {WindowStart}, WindowEnd: {WindowEnd}", windowStart, windowEnd); return; } logger.LogInformation("期刊AI批改任务开始,Count: {Count}, WindowStart: {WindowStart}, WindowEnd: {WindowEnd}", pendingAnswers.Count, windowStart, windowEnd); foreach (var pendingAnswer in pendingAnswers) { try { await ProcessPendingAnswerAsync(client, pendingAnswer.Id, windowStart, windowEnd, CancellationToken.None); } catch (Exception ex) { logger.LogError(ex, "期刊AI批改答案失败,AnswerId: {AnswerId}", pendingAnswer.Id); } } SetLastRunAt(windowEnd); } private async Task ProcessPendingAnswerAsync( ISqlSugarClient client, long answerId, DateTime windowStart, DateTime windowEnd, CancellationToken cancellationToken) { var answer = await client.Queryable() .Where(a => a.Id == answerId && !a.IsDeleted) .FirstAsync(cancellationToken); var answerTime = answer == null ? default : GetPendingTime(answer); if (answer == null || answer.Status != (int)UserAnswerStatusEnum.Processing || answerTime <= windowStart || answerTime > windowEnd) { return; } var task = await client.Queryable() .Where(t => t.Id == answer.JournalPageTaskId && !t.IsDeleted) .FirstAsync(cancellationToken); if (task == null) { logger.LogWarning("期刊AI批改未找到任务,AnswerId: {AnswerId}, TaskId: {TaskId}", answer.Id, answer.JournalPageTaskId); return; } if (!task.NeedAiProcess) { logger.LogInformation("期刊任务配置为人工批改,跳过AI批改,AnswerId: {AnswerId}, TaskId: {TaskId}", answer.Id, task.Id); return; } var originalUpdatedAt = answer.UpdatedAt; var originalAnswerUrl = answer.AnswerUrl ?? string.Empty; try { var scoreUnit = await BuildScoreUnitAsync(client, answer, task, cancellationToken); var referenceAnswerMap = await BuildReferenceAnswerMapAsync(client, scoreUnit, cancellationToken); var answerQuestion = BuildQuestionFromAnswer(answer); var scoreResult = await ScoreQuestionAsync(scoreUnit, answerQuestion, referenceAnswerMap, cancellationToken); var normalizedResult = NormalizeScoreResult(scoreResult, task); await CompleteAnswerAsync(client, answer.Id, originalUpdatedAt, originalAnswerUrl, normalizedResult, cancellationToken); } catch (OperationCanceledException) { throw; } catch (Exception ex) { logger.LogWarning(ex, "期刊AI批改调用失败,保持处理中等待下次重试,AnswerId: {AnswerId}", answer.Id); await client.Updateable() .SetColumns(a => new JournalPageTaskUserAnswer { Result = AiProcessingMessage, UpdatedBy = "AI", UpdatedAt = DateTime.Now }) .Where(a => a.Id == answer.Id && a.Status == (int)UserAnswerStatusEnum.Processing) .ExecuteCommandAsync(cancellationToken); } } private async Task CompleteAnswerAsync( ISqlSugarClient client, long answerId, DateTime? originalUpdatedAt, string originalAnswerUrl, JournalAnswerScoreResult scoreResult, CancellationToken cancellationToken) { client.Ado.BeginTran(); try { var latest = await client.Queryable() .Where(a => a.Id == answerId && !a.IsDeleted) .FirstAsync(cancellationToken); if (latest == null || latest.Status != (int)UserAnswerStatusEnum.Processing || latest.UpdatedAt != originalUpdatedAt || !string.Equals(latest.AnswerUrl ?? string.Empty, originalAnswerUrl, StringComparison.Ordinal)) { client.Ado.CommitTran(); logger.LogInformation("期刊AI批改结果已过期,跳过写入,AnswerId: {AnswerId}", answerId); return; } await client.Insertable(BuildAnswerSnapshot(latest)).ExecuteCommandAsync(cancellationToken); latest.Result = scoreResult.Result; latest.Points = Math.Max(0, scoreResult.Points); latest.GrowthPoints = Math.Max(0, scoreResult.GrowthPoint); latest.Score = Math.Max(0, scoreResult.Score); latest.Comprehension = Math.Max(0, scoreResult.Comprehension); latest.Judgment = Math.Max(0, scoreResult.Judgment); latest.Expression = Math.Max(0, scoreResult.Expression); latest.Persuasiveness = Math.Max(0, scoreResult.Persuasiveness); latest.Status = (int)UserAnswerStatusEnum.Complete; latest.UpdatedBy = "AI"; latest.UpdatedAt = DateTime.Now; await client.Updateable(latest) .IgnoreColumns(a => new { a.CreatedBy, a.CreatedAt }) .Where(a => a.Id == latest.Id) .ExecuteCommandAsync(cancellationToken); client.Ado.CommitTran(); logger.LogInformation("期刊AI批改完成,AnswerId: {AnswerId}, Score: {Score}", latest.Id, latest.Score); } catch { client.Ado.RollbackTran(); throw; } } private async Task BuildScoreUnitAsync( ISqlSugarClient client, JournalPageTaskUserAnswer answer, JournalPageTask task, CancellationToken cancellationToken) { var groupId = task.GroupId > 0 ? task.GroupId : task.Id; var groupTasks = task.GroupId > 0 ? await client.Queryable() .Where(t => t.GroupId == task.GroupId && !t.IsDeleted) .ToListAsync(cancellationToken) : [task]; var pageIds = groupTasks.Select(t => t.JournalPageId).Distinct().ToList(); var pages = await client.Queryable() .Where(p => pageIds.Contains(p.Id) && !p.IsDeleted) .ToListAsync(cancellationToken); var pageMap = pages.ToDictionary(p => p.Id); var question = BuildQuestionFromAnswer(answer); var contexts = groupTasks .OrderBy(t => pageMap.TryGetValue(t.JournalPageId, out var page) ? page.PageNum : 0) .ThenBy(t => ParseTaskNo(t.No)) .ThenBy(t => t.Id) .Select(t => { pageMap.TryGetValue(t.JournalPageId, out var page); return new JournalQuestionContext(question, t, page); }) .ToList(); return new JournalScoreUnit(groupId, contexts); } private static async Task>> BuildReferenceAnswerMapAsync( ISqlSugarClient client, JournalScoreUnit scoreUnit, CancellationToken cancellationToken) { var taskIds = scoreUnit.Questions.Select(q => q.Task.Id).Distinct().ToList(); var referenceAnswers = await client.Queryable() .Where(a => taskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted) .ToListAsync(cancellationToken); return referenceAnswers .GroupBy(a => a.JournalPageTaskId) .ToDictionary(g => g.Key, g => g.ToList()); } private async Task ScoreQuestionAsync( JournalScoreUnit scoreUnit, Question answerQuestion, Dictionary> referenceAnswerMap, CancellationToken cancellationToken) { var apiKey = configuration["AiChat:ApiKey"]; var baseUrl = configuration["AiChat:BaseUrl"]; var model = configuration["AiChat:Model"]; var timeoutSeconds = configuration.GetValue("AiChat:TimeoutSeconds"); var maxTokens = configuration.GetValue("AiChat:MaxTokens"); var temperature = configuration.GetValue("AiChat:Temperature"); if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(model)) { throw new InvalidOperationException("AI聊天服务配置不完整,请检查 AiChat 配置节点"); } var content = new List { new { type = "text", text = BuildScorePrompt(scoreUnit, referenceAnswerMap) } }; var answerImages = await BuildAnswerImageContentsAsync(answerQuestion, cancellationToken); if (answerImages.Count == 0) { throw new InvalidOperationException($"评分单元 {scoreUnit.GroupId} 缺少答案图片"); } content.Add(new { type = "text", text = $"以下是学生作答图片,共 {answerImages.Count} 张。" }); foreach (var answerImage in answerImages) { content.Add(new { type = "image_url", image_url = new { url = answerImage.DataUrl } }); } 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} 张。" }); foreach (var referenceAnswerImage in referenceAnswerImages) { content.Add(new { type = "image_url", image_url = new { url = referenceAnswerImage.DataUrl } }); } } var requestBody = new { model, messages = new object[] { new { role = "system", content = "你是专业的学生作答评分助手。必须只返回合法 JSON,不要返回 Markdown、解释或代码块。" }, new { role = "user", content } }, max_tokens = maxTokens > 0 ? maxTokens : 2000, temperature = temperature is >= 0 ? temperature.Value : 0.1, stream = false, response_format = new { type = "json_object" } }; var requestJson = JsonSerializer.Serialize(requestBody, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); var semaphore = GetAiSemaphore(); await semaphore.WaitAsync(cancellationToken); try { var scoreResult = await SendAiScoreRequestWithRetryAsync( scoreUnit.GroupId, $"{baseUrl.TrimEnd('/')}/chat/completions", apiKey, requestJson, timeoutSeconds > 0 ? timeoutSeconds : 300, cancellationToken); scoreResult.Result = TrimResult(scoreResult.Result); return scoreResult; } finally { semaphore.Release(); } } private static string BuildScorePrompt( JournalScoreUnit scoreUnit, Dictionary> referenceAnswerMap) { var referenceAnswerTexts = scoreUnit.Questions .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 和学生答案为准评分;若有参考答案请结合参考答案。"); if (referenceAnswerTexts.Count > 0) { prompt.AppendLine("参考答案文本:"); for (var i = 0; i < referenceAnswerTexts.Count; i++) { prompt.AppendLine($"{i + 1}. {referenceAnswerTexts[i]}"); } } prompt.AppendLine(); prompt.AppendLine("题目信息:"); foreach (var context in scoreUnit.Questions) { 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("评分要求:"); prompt.AppendLine("- 不得超过题目配置中的各项上限。"); prompt.AppendLine("- 看不清、缺页、无法识别或答案明显不完整时,降低 Completion,不要猜测高分。"); prompt.AppendLine("- Completion 表示作答完整度,范围 0-100。"); prompt.AppendLine("- Result 返回 50 字内中文评语。"); prompt.AppendLine("若作答内容与题目要求不符、答非所问、空白、仅抄题或无法形成有效答案,Score/GrowthPoint/Points/Comprehension/Judgment/Expression/Persuasiveness 均返回 0。"); prompt.AppendLine("若作答内容不符合要求,Result 必须且只能返回:作答内容不符合要求。不要补充原因、建议或其他文字。"); prompt.AppendLine(); prompt.AppendLine("只返回如下 JSON 字段:"); prompt.AppendLine("{"); prompt.AppendLine(" \"Score\": 0,"); prompt.AppendLine(" \"GrowthPoint\": 0,"); prompt.AppendLine(" \"Points\": 0,"); prompt.AppendLine(" \"Comprehension\": 0,"); prompt.AppendLine(" \"Judgment\": 0,"); prompt.AppendLine(" \"Expression\": 0,"); prompt.AppendLine(" \"Persuasiveness\": 0,"); prompt.AppendLine(" \"Completion\": 100,"); prompt.AppendLine(" \"Result\": \"50字内的中文评语\""); prompt.AppendLine("}"); return prompt.ToString(); } private async Task> BuildAnswerImageContentsAsync(Question question, CancellationToken cancellationToken) { var imageUrls = question.AnswerUrl?.Where(url => !string.IsNullOrWhiteSpace(url)).Distinct().ToList() ?? []; var result = new List(); foreach (var imageUrl in imageUrls) { var imageContent = await BuildImageContentAsync(imageUrl, question.Id, "答案图片", true, cancellationToken); if (imageContent != null) { result.Add(imageContent); } } return result; } private async Task> BuildReferenceAnswerImageContentsAsync( long taskId, List referenceAnswers, CancellationToken cancellationToken) { var imageUrls = referenceAnswers .Select(a => a.AnswerUrL) .Where(url => !string.IsNullOrWhiteSpace(url)) .Distinct() .Select(url => url!) .ToList(); var result = new List(); foreach (var imageUrl in imageUrls) { var imageContent = await BuildImageContentAsync(imageUrl, taskId, "参考答案图片", false, cancellationToken); if (imageContent != null) { result.Add(imageContent); } } return result; } private async Task BuildImageContentAsync( string imageUrl, long taskId, string imageRole, bool required, CancellationToken cancellationToken) { try { cancellationToken.ThrowIfCancellationRequested(); await using var imageStream = await GetImageStreamAsync(imageUrl, cancellationToken); if (imageStream == null) { throw new InvalidOperationException($"{imageRole}读取失败,TaskId: {taskId}, Url: {imageUrl}"); } using var memoryStream = new MemoryStream(); await imageStream.CopyToAsync(memoryStream, cancellationToken); if (memoryStream.Length == 0) { throw new InvalidOperationException($"{imageRole}内容为空,TaskId: {taskId}, Url: {imageUrl}"); } var maxImageBytes = configuration.GetValue("AiChat:MaxImageBytes"); if (maxImageBytes <= 0) { maxImageBytes = DefaultMaxImageBytes; } if (memoryStream.Length > maxImageBytes) { throw new InvalidOperationException($"{imageRole}超过大小限制,TaskId: {taskId}, Url: {imageUrl}, Size: {memoryStream.Length}"); } var imageBytes = memoryStream.ToArray(); var mimeType = GetImageMimeType(imageUrl, imageBytes); return new AnswerImageContent($"data:{mimeType};base64,{Convert.ToBase64String(imageBytes)}"); } catch (OperationCanceledException) { throw; } catch (Exception ex) when (!required) { logger.LogWarning(ex, "{ImageRole}读取失败,已跳过,TaskId: {TaskId}, Url: {Url}", imageRole, taskId, imageUrl); return null; } } private async Task GetImageStreamAsync(string imageUrl, CancellationToken cancellationToken) { if (imageUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || imageUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { var httpClient = httpClientFactory.CreateClient(); return await httpClient.GetStreamAsync(imageUrl, cancellationToken); } return ossService.GetObjectStream(imageUrl); } private async Task SendAiScoreRequestWithRetryAsync( long taskId, string requestUrl, string apiKey, string requestJson, int timeoutSeconds, CancellationToken cancellationToken) { var maxRetryCount = configuration.GetValue("AiChat:ScoreMaxRetryCount"); if (maxRetryCount <= 0) { maxRetryCount = DefaultAiScoreMaxRetryCount; } var retryDelayMilliseconds = configuration.GetValue("AiChat:ScoreRetryDelayMilliseconds"); if (retryDelayMilliseconds <= 0) { retryDelayMilliseconds = DefaultAiScoreRetryDelayMilliseconds; } Exception? lastException = null; for (var attempt = 1; attempt <= maxRetryCount; attempt++) { cancellationToken.ThrowIfCancellationRequested(); try { var httpClient = httpClientFactory.CreateClient(); httpClient.Timeout = TimeSpan.FromSeconds(timeoutSeconds); using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Content = new StringContent(requestJson, Encoding.UTF8, "application/json"); using var response = await httpClient.SendAsync(request, cancellationToken); var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); if (response.IsSuccessStatusCode) { 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) { throw new InvalidOperationException($"AI评分调用失败:{response.StatusCode},响应:{responseContent}"); } } catch (OperationCanceledException) { throw; } catch (NonRetryAiScoreException) { throw; } catch (Exception ex) when (attempt < maxRetryCount) { lastException = ex; logger.LogWarning(ex, "AI评分调用异常,准备重试,TaskId: {TaskId}, Attempt: {Attempt}/{MaxRetryCount}", taskId, attempt, maxRetryCount); } await Task.Delay(TimeSpan.FromMilliseconds(retryDelayMilliseconds * attempt), cancellationToken); } throw new InvalidOperationException($"AI评分调用失败,TaskId: {taskId}", lastException); } private static bool ShouldRetry(System.Net.HttpStatusCode statusCode) { var status = (int)statusCode; return status == 408 || status == 429 || status >= 500; } private static string GetImageMimeType(string imageUrl, byte[] imageBytes) { if (imageBytes.Length >= 4) { if (imageBytes[0] == 0x89 && imageBytes[1] == 0x50 && imageBytes[2] == 0x4E && imageBytes[3] == 0x47) { return "image/png"; } if (imageBytes[0] == 0xFF && imageBytes[1] == 0xD8) { return "image/jpeg"; } if (imageBytes[0] == 0x47 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46) { return "image/gif"; } if (imageBytes[0] == 0x52 && imageBytes[1] == 0x49 && imageBytes[2] == 0x46 && imageBytes[3] == 0x46) { return "image/webp"; } } return Path.GetExtension(imageUrl).ToLowerInvariant() switch { ".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg", ".gif" => "image/gif", ".webp" => "image/webp", _ => "image/jpeg" }; } private static string ExtractAssistantContent(string responseContent) { using var document = JsonDocument.Parse(responseContent); var message = document.RootElement.GetProperty("choices")[0].GetProperty("message"); if (!message.TryGetProperty("content", out var contentElement)) { throw new NonRetryAiScoreException("AI评分响应缺少 content"); } return contentElement.ValueKind == JsonValueKind.String ? contentElement.GetString() ?? string.Empty : contentElement.GetRawText(); } private static JournalAnswerScoreResult ParseScoreResult(string resultJson) { var cleanedJson = CleanJsonContent(resultJson); EnsureRequiredScoreFields(cleanedJson); var result = JsonSerializer.Deserialize(cleanedJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); return result ?? throw new NonRetryAiScoreException("AI评分结果解析失败"); } private static string CleanJsonContent(string content) { var text = content.Trim(); if (text.StartsWith("```", StringComparison.Ordinal)) { var firstLineEnd = text.IndexOf('\n'); if (firstLineEnd >= 0) { text = text[(firstLineEnd + 1)..]; } var fenceIndex = text.LastIndexOf("```", StringComparison.Ordinal); if (fenceIndex >= 0) { text = text[..fenceIndex]; } } var start = text.IndexOf('{'); var end = text.LastIndexOf('}'); if (start >= 0 && end > start) { text = text[start..(end + 1)]; } 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 NonRetryAiScoreException($"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 = NormalizeResult(scoreResult.Result) }; } private static string NormalizeResult(string? result) { var trimmedResult = TrimResult(result); if (trimmedResult.Contains("不符合要求", StringComparison.Ordinal) || trimmedResult.Contains("不符", StringComparison.Ordinal)) { return InvalidAnswerResult; } return trimmedResult; } 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 Question BuildQuestionFromAnswer(JournalPageTaskUserAnswer answer) { return new Question { Id = answer.JournalPageTaskId, Url = answer.QuestionAnswerUrl ?? string.Empty, AnswerUrl = DeserializeStringArray(answer.AnswerUrl), AnswerStartTime = answer.AnswerStartTime, AnswerEndTime = answer.AnswerEndTime, AnswerTime = answer.AnswerSeconds, BreakCount = answer.BreakCount, BreakTimes = DeserializeBreakTimes(answer.BreakTimes) }; } private static string[] DeserializeStringArray(string? json) { if (string.IsNullOrWhiteSpace(json)) { return []; } try { return JsonSerializer.Deserialize(json) ?? []; } catch { return []; } } private static List DeserializeBreakTimes(string? json) { if (string.IsNullOrWhiteSpace(json)) { return []; } try { return JsonSerializer.Deserialize>(json) ?? []; } catch { return []; } } private static JournalPageTaskUserAnswerSnapshot BuildAnswerSnapshot(JournalPageTaskUserAnswer answer) { var now = DateTime.Now; return new JournalPageTaskUserAnswerSnapshot { JournalPageTaskUserAnswerId = answer.Id, JournalId = answer.JournalId, JournalPageId = answer.JournalPageId, JournalPageTaskId = answer.JournalPageTaskId, JournalPageTaskGroupId = answer.JournalPageTaskGroupId, UserId = answer.UserId, Result = answer.Result, Points = answer.Points, GrowthPoints = answer.GrowthPoints, Score = answer.Score, Comprehension = answer.Comprehension, Judgment = answer.Judgment, Expression = answer.Expression, Persuasiveness = answer.Persuasiveness, QuestionAnswerUrl = answer.QuestionAnswerUrl, AnswerUrl = answer.AnswerUrl, PageAnswerUrl = answer.PageAnswerUrl, Revision = answer.Revision, AnswerStartTime = answer.AnswerStartTime, AnswerEndTime = answer.AnswerEndTime, AnswerSeconds = answer.AnswerSeconds, ImageRecognition = answer.ImageRecognition, JournalPageNum = answer.JournalPageNum, Modify = answer.Modify, LastTag = answer.LastTag, DotPageNum = answer.DotPageNum, PageResultUrl = answer.PageResultUrl, Type = answer.Type, DotPageNo = answer.DotPageNo, PageAnswerDotUrl = answer.PageAnswerDotUrl, BreakCount = answer.BreakCount, BreakTimes = answer.BreakTimes, AssignmentStatus = answer.AssignmentStatus, Status = answer.Status, CreatedBy = answer.UpdatedBy ?? answer.CreatedBy ?? string.Empty, CreatedAt = now, UpdatedBy = answer.UpdatedBy ?? string.Empty, UpdatedAt = now }; } private SemaphoreSlim GetAiSemaphore() { var maxConcurrency = configuration.GetValue("AiChat:MaxConcurrency"); if (maxConcurrency <= 0) { maxConcurrency = DefaultAiMaxConcurrency; } lock (AiSemaphoreLock) { if (aiSemaphore == null || aiSemaphoreLimit != maxConcurrency) { aiSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency); aiSemaphoreLimit = maxConcurrency; } return aiSemaphore; } } private int GetPendingAnswerMinutes() { var minutes = configuration.GetValue("AiChat:PendingAnswerMinutes"); return minutes > 0 ? minutes : DefaultPendingAnswerMinutes; } private int GetPendingAnswerBatchSize() { var batchSize = configuration.GetValue("AiChat:PendingAnswerBatchSize"); return batchSize > 0 ? batchSize : DefaultPendingAnswerBatchSize; } private DateTime GetWindowStart(DateTime windowEnd) { lock (RunWindowLock) { return lastRunAt ?? windowEnd.AddMinutes(-GetPendingAnswerMinutes()); } } private static void SetLastRunAt(DateTime runAt) { lock (RunWindowLock) { lastRunAt = runAt; } } private static DateTime GetPendingTime(JournalPageTaskUserAnswer answer) { return answer.UpdatedAt ?? answer.CreatedAt; } private static int ParseTaskNo(string? taskNo) { return int.TryParse(taskNo, out var no) ? no : int.MaxValue; } private static string TrimResult(string? result) { if (string.IsNullOrWhiteSpace(result)) { return string.Empty; } return result.Length <= 50 ? result : result[..50]; } }