From 12d488a5ca18eab275d1d57ff98d357dd962d7d5 Mon Sep 17 00:00:00 2001 From: glz <694770232@qq.com> Date: Thu, 2 Jul 2026 16:05:13 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96AI=E8=AF=84?= =?UTF-8?q?=E5=88=86=E4=B8=8E=E4=BA=8C=E7=BB=B4=E7=A0=81ID=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=80=BB=E8=BE=91=EF=BC=8C=E8=B0=83=E6=95=B4RabbitMQ?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 调整RabbitMQ预取计数配置,支持从配置读取 2. 新增随机ID帮助类,生成唯一长整型ID 3. 重构二维码ID生成逻辑,新增重试机制避免重复 4. 优化AI评分配置,调整温度系数与并发限制 5. 重构跨页题评分逻辑,支持分组评分与结果去重 6. 新增AI评分异常分类与结果校验逻辑 7. 优化评分提示词与结果归一化处理 --- .../Helpers/RandomIdHelper.cs | 37 ++ .../RabbitMQ/RabbitMQService.cs | 13 +- .../UserJournalService.cs | 54 +- .../Consumers/JournalTaskReceiveConsumer.cs | 499 +++++++++++++++--- .../appsettings.json | 6 +- 5 files changed, 524 insertions(+), 85 deletions(-) create mode 100644 QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs diff --git a/QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs b/QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs new file mode 100644 index 0000000..78fd452 --- /dev/null +++ b/QYZH.InteractiveMagazine.Common/Helpers/RandomIdHelper.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; + +namespace QYZH.InteractiveMagazine.Common.Helpers; + +/// +/// 随机ID帮助类 +/// +public static class RandomIdHelper +{ + private const long DefaultMinValue = 1_000_000_000_000_000_000L; + private const long DefaultMaxValue = 9_000_000_000_000_000_000L; + + /// + /// 生成不可预测的正数长整型ID + /// + public static long GenerateLongId(long minValue = DefaultMinValue, long maxValue = DefaultMaxValue) + { + if (minValue <= 0 || minValue >= maxValue) + { + throw new ArgumentOutOfRangeException(nameof(minValue), "随机ID范围配置错误"); + } + + var range = (ulong)(maxValue - minValue); + var limit = ulong.MaxValue - (ulong.MaxValue % range); + + Span bytes = stackalloc byte[sizeof(ulong)]; + while (true) + { + RandomNumberGenerator.Fill(bytes); + var value = BitConverter.ToUInt64(bytes); + if (value < limit) + { + return minValue + (long)(value % range); + } + } + } +} diff --git a/QYZH.InteractiveMagazine.Infrastructure/RabbitMQ/RabbitMQService.cs b/QYZH.InteractiveMagazine.Infrastructure/RabbitMQ/RabbitMQService.cs index 2a168e8..7d1cbc1 100644 --- a/QYZH.InteractiveMagazine.Infrastructure/RabbitMQ/RabbitMQService.cs +++ b/QYZH.InteractiveMagazine.Infrastructure/RabbitMQ/RabbitMQService.cs @@ -1,5 +1,6 @@ using RabbitMQ.Client; using RabbitMQ.Client.Events; +using Microsoft.Extensions.Configuration; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; @@ -9,13 +10,15 @@ namespace QYZH.InteractiveMagazine.Infrastructure.RabbitMQ public class RabbitMQService : IRabbitMQService { private readonly IRabbitMQConnection _connection; + private readonly IConfiguration _configuration; private readonly JsonSerializerOptions options = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; - public RabbitMQService(IRabbitMQConnection connection) + public RabbitMQService(IRabbitMQConnection connection, IConfiguration configuration) { _connection = connection ?? throw new ArgumentNullException(nameof(connection)); + _configuration = configuration; } @@ -130,7 +133,13 @@ namespace QYZH.InteractiveMagazine.Infrastructure.RabbitMQ public async Task ReceiveAsync(string exchange, string queueName, string routingKey, Func callback, CancellationToken cancellationToken = default) { var channel = await _connection.CreateChannel(); - //await channel.BasicQosAsync(0, 10, false); // 一次最多接收10条未确认的消息 + var prefetchCount = _configuration.GetValue("RabbitMq:PrefetchCount"); + if (prefetchCount == 0) + { + prefetchCount = 1; + } + + await channel.BasicQosAsync(0, prefetchCount, false, cancellationToken); // 声明 Exchange(持久化) await channel.ExchangeDeclareAsync(exchange: exchange, type: "direct", durable: true, autoDelete: false, arguments: null); diff --git a/QYZH.InteractiveMagazine.Service/UserJournalService.cs b/QYZH.InteractiveMagazine.Service/UserJournalService.cs index 75f67e0..3f3b1b4 100644 --- a/QYZH.InteractiveMagazine.Service/UserJournalService.cs +++ b/QYZH.InteractiveMagazine.Service/UserJournalService.cs @@ -32,6 +32,7 @@ public class UserJournalService( private const string QrCodeGenerateQueue = "mq.journal.qrcode.generate"; private const string QrCodeGenerateRoutingKey = "rk.journal.qrcode.generate"; private const int MaxBatchQrCodeCount = 500; + private const int MaxRandomIdGenerateRetryCount = 5; /// /// 用户绑定期刊(扫码绑定) @@ -183,8 +184,10 @@ public class UserJournalService( throw new BusinessException("该期刊暂未发布,无法生成二维码", ResultCode.UNPROCESSABLE_ENTITY); } + var recordId = await GenerateUniqueQrCodeIdAsync(); var record = new UserJournal { + Id = recordId, UserId = null, JournalId = input.JournalId, Type = 0, @@ -243,9 +246,11 @@ public class UserJournalService( } var now = DateTime.Now; - var records = Enumerable.Range(0, input.Count) - .Select(_ => new UserJournal + var randomIds = await GenerateUniqueQrCodeIdsAsync(input.Count); + var records = randomIds + .Select(id => new UserJournal { + Id = id, UserId = null, JournalId = input.JournalId, Type = 0, @@ -459,6 +464,51 @@ public class UserJournalService( return JsonSerializer.Serialize(new { JournalId = journalId, Id = id }); } + private async Task GenerateUniqueQrCodeIdAsync() + { + for (var i = 0; i < MaxRandomIdGenerateRetryCount; i++) + { + var id = RandomIdHelper.GenerateLongId(); + var exists = await userJournalRepository.Queryable() + .AnyAsync(uj => uj.Id == id); + + if (!exists) + { + return id; + } + } + + throw new BusinessException("生成二维码ID失败,请稍后重试", ResultCode.GLOBAL_ERROR); + } + + private async Task> GenerateUniqueQrCodeIdsAsync(int count) + { + var ids = new HashSet(); + + for (var i = 0; i < MaxRandomIdGenerateRetryCount && ids.Count < count; i++) + { + while (ids.Count < count) + { + ids.Add(RandomIdHelper.GenerateLongId()); + } + + var candidateIds = ids.ToList(); + var existingIds = await userJournalRepository.Queryable() + .Where(uj => candidateIds.Contains(uj.Id)) + .Select(uj => uj.Id) + .ToListAsync(); + + if (existingIds.Count == 0) + { + return candidateIds; + } + + ids.ExceptWith(existingIds); + } + + throw new BusinessException("生成二维码ID失败,请稍后重试", ResultCode.GLOBAL_ERROR); + } + private async Task SendBindJournalMessageAsync(Users user, Journal journal) { try diff --git a/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs b/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs index 6152a7d..eac5b1b 100644 --- a/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs +++ b/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs @@ -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() + .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() - .Where(a => taskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted) + .Where(a => groupTaskIds.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() - .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() + .Where(p => pageIds.Contains(p.Id) && !p.IsDeleted) + .ToListAsync(cancellationToken); + var pageMap = pages.ToDictionary(p => p.Id); + var existingAnswers = await client.Queryable() + .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(); var answerContexts = new List(); foreach (var question in data.Questions) { @@ -80,13 +106,55 @@ public class JournalTaskReceiveConsumer( continue; } - referenceAnswerMap.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)); + pageMap.TryGetValue(task.JournalPageId, 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 ScoreQuestionAsync( - JournalPageTask task, - Question question, - List referenceAnswers, + JournalScoreUnit scoreUnit, + Dictionary> referenceAnswerMap, CancellationToken cancellationToken) { var apiKey = configuration["AiChat:ApiKey"]; @@ -158,45 +225,61 @@ public class JournalTaskReceiveConsumer( var model = configuration["AiChat:Model"]; var timeoutSeconds = configuration.GetValue("AiChat:TimeoutSeconds"); var maxTokens = configuration.GetValue("AiChat:MaxTokens"); - var temperature = configuration.GetValue("AiChat:Temperature"); + var temperature = configuration.GetValue("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 { new { type = "text", - text = BuildScorePrompt(task, question, answerImages.Count, referenceAnswers, referenceAnswerImages.Count) + text = BuildScorePrompt(scoreUnit, referenceAnswerMap) } }; - foreach (var answerImage in answerImages) - { - content.Add(new - { - type = "image_url", - image_url = new { url = answerImage.DataUrl } - }); - } - - if (referenceAnswerImages.Count > 0) + var totalAnswerImageCount = 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(resultJson); - 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, + requestJson, + 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 referenceAnswers, - int referenceAnswerImageCount) + JournalScoreUnit scoreUnit, + Dictionary> referenceAnswerMap) { - var referenceAnswerTexts = referenceAnswers + 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 和学生答案为准评分。"); - 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 SendAiScoreRequestWithRetryAsync( + private async Task 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 (OperationCanceledException 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(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 BuildScoreUnits(List 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 persistedTaskMap, + Dictionary 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 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("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 = JsonSerializer.Serialize(question.AnswerUrl ?? []), + AnswerUrl = SerializeAnswerUrls(scoreUnit), PageAnswerUrl = data.PageAnswerUrl, Revision = 0, - AnswerStartTime = question.AnswerStartTime, - AnswerEndTime = question.AnswerEndTime, - AnswerSeconds = question.AnswerTime, + AnswerStartTime = GetAnswerStartTime(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 = JsonSerializer.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 Questions); + public record AnswerImageContent(string DataUrl); + +/// +/// AI评分不可重试异常。 +/// +public class NonRetryAiScoreException(string message) : Exception(message); diff --git a/QYZH.InteractiveMagazine.WorkService/appsettings.json b/QYZH.InteractiveMagazine.WorkService/appsettings.json index 3585c67..bb9a4f7 100644 --- a/QYZH.InteractiveMagazine.WorkService/appsettings.json +++ b/QYZH.InteractiveMagazine.WorkService/appsettings.json @@ -12,7 +12,8 @@ "Port": 5672, "UserName": "smartschool", "Password": "@ss%&*otz%d*pq2S", - "VirtualHost": "InteractiveMagazine" + "VirtualHost": "InteractiveMagazine", + "PrefetchCount": 1 }, "Serilog": { "MinimumLevel": { @@ -55,7 +56,8 @@ "Model": "qwen2.5vl:7b", "TimeoutSeconds": 300, "MaxTokens": 2000, - "Temperature": 0.5, + "Temperature": 0.1, + "MaxConcurrency": 1, "ScoreMaxRetryCount": 3, "ScoreRetryDelayMilliseconds": 1000, "MaxImageBytes": 10485760,