diff --git a/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs b/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs index 62cb17f..3bbf4f2 100644 --- a/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs +++ b/QYZH.InteractiveMagazine.WorkService/Consumers/JournalTaskReceiveConsumer.cs @@ -1,11 +1,8 @@ -using QYZH.InteractiveMagazine.Infrastructure.OSS; using QYZH.InteractiveMagazine.Models.Entity; using QYZH.InteractiveMagazine.Models.Enum; using SqlSugar; -using System.Net.Http.Headers; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; namespace QYZH.InteractiveMagazine.WorkService.Consumers; @@ -15,21 +12,10 @@ namespace QYZH.InteractiveMagazine.WorkService.Consumers; public class JournalTaskReceiveConsumer( ILogger logger, IConfiguration configuration, - IServiceScopeFactory scopeFactory, - IHttpClientFactory httpClientFactory, - OssService ossService) : IQueueConsumer + IServiceScopeFactory scopeFactory) : IQueueConsumer { - 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 InvalidAnswerResult = "作答内容不符合要求"; private const string AiProcessingMessage = "AI批阅中,请稍后"; - private static readonly object AiSemaphoreLock = new(); - private static SemaphoreSlim? aiSemaphore; - private static int aiSemaphoreLimit; public string Exchange => "ex.journal"; @@ -63,7 +49,7 @@ public class JournalTaskReceiveConsumer( 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) + .Where(t => (groupIds.Contains(t.GroupId) || groupIds.Contains(t.Id)) && !t.IsDeleted) .ToListAsync(cancellationToken); var persistedTaskMap = groupTasks .GroupBy(t => t.GroupId > 0 ? t.GroupId : t.Id) @@ -71,13 +57,6 @@ public class JournalTaskReceiveConsumer( 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 => groupTaskIds.Contains(a.JournalPageTaskId) && !a.IsDeleted) - .ToListAsync(cancellationToken); - var referenceAnswerMap = referenceAnswers - .GroupBy(a => a.JournalPageTaskId) - .ToDictionary(g => g.Key, g => g.ToList()); var pageIds = groupTasks.Select(t => t.JournalPageId).Append(data.PageId).Distinct().ToList(); var pages = await client.Queryable() @@ -85,15 +64,7 @@ public class JournalTaskReceiveConsumer( .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) { if (!taskMap.TryGetValue(question.Id, out var task)) @@ -102,61 +73,27 @@ public class JournalTaskReceiveConsumer( continue; } - if (!task.NeedAiProcess) - { - logger.LogInformation("期刊任务配置为人工批改,跳过AI评分,TaskId: {TaskId}, UserId: {UserId}", task.Id, data.UserId); - continue; - } - pageMap.TryGetValue(task.JournalPageId, out var taskPage); questionContexts.Add(new JournalQuestionContext(question, task, taskPage)); } + var answerContexts = new List(); 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); + 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); - } + var processingResult = BuildFailureScoreResult(); + answerContexts.Add(new JournalAnswerContext( + BuildAnswerEntity(data, scoreUnit, persistedContext.Question, persistedContext.Task, persistedContext.Page, processingResult, GetCompletionThreshold()), + persistedContext.Question, + persistedContext.Task, + processingResult)); + LogSkippedGroupTasks(scoreUnit, persistedContext.Task); } if (answerContexts.Count == 0) @@ -178,24 +115,19 @@ public class JournalTaskReceiveConsumer( if (existing == null) { await client.Insertable(answer).ExecuteCommandAsync(cancellationToken); - } - else - { - await client.Insertable(BuildAnswerSnapshot(existing)).ExecuteCommandAsync(cancellationToken); - - answer.Id = existing.Id; - answer.CreatedBy = existing.CreatedBy; - answer.CreatedAt = existing.CreatedAt; - answer.UpdatedBy = answer.UserId.ToString(); - answer.UpdatedAt = DateTime.Now; - - await client.Updateable(answer) - .IgnoreColumns(a => new { a.CreatedBy, a.CreatedAt }) - .Where(a => a.Id == existing.Id) - .ExecuteCommandAsync(cancellationToken); + continue; } - await InsertCommunityMessageIfNeededAsync(client, context, cancellationToken); + answer.Id = existing.Id; + answer.CreatedBy = existing.CreatedBy; + answer.CreatedAt = existing.CreatedAt; + answer.UpdatedBy = answer.UserId.ToString(); + answer.UpdatedAt = DateTime.Now; + + await client.Updateable(answer) + .IgnoreColumns(a => new { a.CreatedBy, a.CreatedAt }) + .Where(a => a.Id == existing.Id) + .ExecuteCommandAsync(cancellationToken); } client.Ado.CommitTran(); @@ -206,10 +138,9 @@ public class JournalTaskReceiveConsumer( throw; } - logger.LogInformation("期刊任务答题记录保存完成,UserId: {UserId}, JournalId: {JournalId}, PageId: {PageId}, Count: {Count}", + logger.LogInformation("期刊任务上传数据保存完成,UserId: {UserId}, JournalId: {JournalId}, PageId: {PageId}, Count: {Count}", data.UserId, data.JournalId, data.PageId, answerContexts.Count); } - public Task OnErrorAsync(byte[] body, Exception exception) { var message = Encoding.UTF8.GetString(body); @@ -217,537 +148,6 @@ public class JournalTaskReceiveConsumer( return Task.CompletedTask; } - private async Task ScoreQuestionAsync( - JournalScoreUnit scoreUnit, - 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 answerMapPrompt = BuildScorePrompt(scoreUnit, referenceAnswerMap); - logger.LogInformation(answerMapPrompt); - var content = new List - { - new - { - type = "text", - text = answerMapPrompt - } - }; - - 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 = $"以下是学生作答图片,共 {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} 缺少答案图片"); - } - - 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 maxImageBytes = configuration.GetValue("AiChat:MaxImageBytes"); - if (maxImageBytes <= 0) - { - maxImageBytes = DefaultMaxImageBytes; - } - - var result = new List(); - foreach (var imageUrl in imageUrls) - { - cancellationToken.ThrowIfCancellationRequested(); - - await using var imageStream = await GetImageStreamAsync(imageUrl, cancellationToken); - if (imageStream == null) - { - throw new InvalidOperationException($"答案图片读取失败,TaskId: {question.Id}, Url: {imageUrl}"); - } - - using var memoryStream = new MemoryStream(); - await imageStream.CopyToAsync(memoryStream, cancellationToken); - if (memoryStream.Length == 0) - { - throw new InvalidOperationException($"答案图片内容为空,TaskId: {question.Id}, Url: {imageUrl}"); - } - - if (memoryStream.Length > maxImageBytes) - { - throw new InvalidOperationException($"答案图片超过大小限制,TaskId: {question.Id}, Url: {imageUrl}, Size: {memoryStream.Length}"); - } - - var imageBytes = memoryStream.ToArray(); - var mimeType = GetImageMimeType(imageUrl, imageBytes); - result.Add(new AnswerImageContent($"data:{mimeType};base64,{Convert.ToBase64String(imageBytes)}")); - } - - 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 maxImageBytes = configuration.GetValue("AiChat:MaxImageBytes"); - if (maxImageBytes <= 0) - { - maxImageBytes = DefaultMaxImageBytes; - } - - var result = new List(); - foreach (var imageUrl in imageUrls) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - await using var imageStream = await GetImageStreamAsync(imageUrl, cancellationToken); - if (imageStream == null) - { - throw new InvalidOperationException($"参考答案图片读取失败,TaskId: {taskId}, Url: {imageUrl}"); - } - - using var memoryStream = new MemoryStream(); - await imageStream.CopyToAsync(memoryStream, cancellationToken); - if (memoryStream.Length == 0) - { - throw new InvalidOperationException($"参考答案图片内容为空,TaskId: {taskId}, Url: {imageUrl}"); - } - - if (memoryStream.Length > maxImageBytes) - { - throw new InvalidOperationException($"参考答案图片超过大小限制,TaskId: {taskId}, Url: {imageUrl}, Size: {memoryStream.Length}"); - } - - var imageBytes = memoryStream.ToArray(); - var mimeType = GetImageMimeType(imageUrl, imageBytes); - result.Add(new AnswerImageContent($"data:{mimeType};base64,{Convert.ToBase64String(imageBytes)}")); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) - { - logger.LogWarning(ex, "参考答案图片读取失败,已跳过,TaskId: {TaskId}, Url: {Url}", taskId, imageUrl); - } - } - - return result; - } - - 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)) - { - throw new NonRetryAiScoreException($"AI评分调用失败:{response.StatusCode},响应:{responseContent}"); - } - - if (attempt == maxRetryCount) - { - throw new InvalidOperationException($"AI评分调用失败:{response.StatusCode},响应:{responseContent}"); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - 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); - } - - var delay = TimeSpan.FromMilliseconds(retryDelayMilliseconds * attempt); - await Task.Delay(delay, cancellationToken); - } - - throw new InvalidOperationException($"AI评分调用失败,TaskId: {taskId}", lastException); - } - - private async Task GetImageStreamAsync(string imageUrl, CancellationToken cancellationToken) - { - if (Uri.TryCreate(imageUrl, UriKind.Absolute, out var uri) - && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) - { - var httpClient = httpClientFactory.CreateClient(); - return await httpClient.GetStreamAsync(uri, cancellationToken); - } - - return ossService.GetObjectStream(imageUrl); - } - - 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"; - } - } - - var extension = Path.GetExtension(imageUrl).ToLowerInvariant(); - return extension 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 InvalidOperationException("AI评分响应缺少 content"); - } - - if (contentElement.ValueKind == JsonValueKind.String) - { - return contentElement.GetString() ?? string.Empty; - } - - return 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 InvalidOperationException("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 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 = 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 JournalAnswerScoreResult BuildFailureScoreResult() { return new JournalAnswerScoreResult @@ -756,7 +156,6 @@ public class JournalTaskReceiveConsumer( Result = AiProcessingMessage }; } - private static List BuildScoreUnits(List contexts) { return contexts @@ -795,41 +194,6 @@ public class JournalTaskReceiveConsumer( 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 @@ -954,111 +318,6 @@ public class JournalTaskReceiveConsumer( }; } - 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 async Task InsertCommunityMessageIfNeededAsync( - ISqlSugarClient client, - JournalAnswerContext context, - CancellationToken cancellationToken) - { - var scoreThreshold = configuration.GetValue("AiChat:CommunityScoreThreshold"); - if (scoreThreshold <= 0) - { - scoreThreshold = DefaultCommunityScoreThreshold; - } - - if (context.Answer.Status != (int)UserAnswerStatusEnum.Complete || context.ScoreResult.Score < scoreThreshold) - { - return; - } - - var exists = await client.Queryable() - .Where(m => m.JournalTaskAnswerId == context.Answer.Id && !m.IsDeleted) - .AnyAsync(cancellationToken); - if (exists) - { - return; - } - - var userJournal = await client.Queryable() - .Where(uj => uj.UserId == context.Answer.UserId && uj.JournalId == context.Answer.JournalId && !uj.IsDeleted) - .FirstAsync(cancellationToken); - if (userJournal == null) - { - logger.LogWarning("高分答案未找到用户期刊关系,跳过社区消息写入,UserId: {UserId}, JournalId: {JournalId}, AnswerId: {AnswerId}", - context.Answer.UserId, context.Answer.JournalId, context.Answer.Id); - return; - } - - var now = DateTime.Now; - var communityMessage = new CommunityMessage - { - JournalId = context.Answer.JournalId, - UserId = context.Answer.UserId, - UserJournalId = userJournal.Id, - JournalTaskId = context.Answer.JournalPageTaskId, - JournalTaskAnswerId = context.Answer.Id, - Content = context.ScoreResult.Result, - ImageUrl = context.Question.AnswerUrl.FirstOrDefault() ?? string.Empty, - SortOrder = 0, - IsActive = true, - Type = MessageTypeEnum.Message, - LikeCount = 0, - IsFeatured = 0, - Status = 1, - CreatedBy = context.Answer.UserId.ToString(), - CreatedAt = now, - UpdatedBy = context.Answer.UserId.ToString(), - UpdatedAt = now - }; - - await client.Insertable(communityMessage).ExecuteCommandAsync(cancellationToken); - } - private float GetCompletionThreshold() { var threshold = configuration.GetValue("AiChat:CompletionThreshold"); diff --git a/QYZH.InteractiveMagazine.WorkService/Jobs/JournalTaskAiScoreJob.cs b/QYZH.InteractiveMagazine.WorkService/Jobs/JournalTaskAiScoreJob.cs new file mode 100644 index 0000000..de81194 --- /dev/null +++ b/QYZH.InteractiveMagazine.WorkService/Jobs/JournalTaskAiScoreJob.cs @@ -0,0 +1,940 @@ +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]; + } +} diff --git a/QYZH.InteractiveMagazine.WorkService/appsettings.json b/QYZH.InteractiveMagazine.WorkService/appsettings.json index bb9a4f7..ac4e82f 100644 --- a/QYZH.InteractiveMagazine.WorkService/appsettings.json +++ b/QYZH.InteractiveMagazine.WorkService/appsettings.json @@ -47,6 +47,14 @@ "Cron": "* * * * *", "Enabled": false, "Description": "示例任务(默认关闭,仅用于验证框架运行)" + }, + { + "Name": "journal-task-ai-score-job", + "JobType": "QYZH.InteractiveMagazine.WorkService.Jobs.JournalTaskAiScoreJob", + "MethodName": "Execute", + "Cron": "*/30 * * * *", + "Enabled": true, + "Description": "每30分钟扫描上次执行到本次执行之间的期刊答题记录并提交AI批改" } ] }, @@ -62,7 +70,8 @@ "ScoreRetryDelayMilliseconds": 1000, "MaxImageBytes": 10485760, "CompletionThreshold": 80, - "CommunityScoreThreshold": 90 + "PendingAnswerMinutes": 30, + "PendingAnswerBatchSize": 100 }, "AllowedHosts": "*", "AliyunOSSConfigs": {